enact-core 0.0.2

Core agent runtime for Enact - Graph-Native AI agents
Documentation
//! HTTP request tool

use crate::tool::Tool;
use async_trait::async_trait;
use serde_json::json;
use std::collections::HashMap;
use std::time::Duration;

const DEFAULT_TIMEOUT_SECS: u64 = 30;
const MAX_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10MB

/// HTTP request tool for making API calls
pub struct HttpRequestTool;

impl HttpRequestTool {
    pub fn new() -> Self {
        Self
    }
}

impl Default for HttpRequestTool {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl Tool for HttpRequestTool {
    fn name(&self) -> &str {
        "http_request"
    }

    fn description(&self) -> &str {
        "Make HTTP requests (GET, POST, PUT, DELETE, etc.) to APIs and web services"
    }

    fn parameters_schema(&self) -> serde_json::Value {
        json!({
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "URL to request"
                },
                "method": {
                    "type": "string",
                    "enum": ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
                    "default": "GET"
                },
                "headers": {
                    "type": "object",
                    "description": "HTTP headers as key-value pairs"
                },
                "body": {
                    "type": ["string", "object"],
                    "description": "Request body (for POST, PUT, PATCH)"
                },
                "timeout": {
                    "type": "integer",
                    "description": "Timeout in seconds (default: 30)",
                    "minimum": 1,
                    "maximum": 300
                }
            },
            "required": ["url"]
        })
    }

    fn requires_network(&self) -> bool {
        true
    }

    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<serde_json::Value> {
        let url = args
            .get("url")
            .and_then(|v| v.as_str())
            .ok_or_else(|| anyhow::anyhow!("Missing 'url' parameter"))?;

        let method = args.get("method").and_then(|v| v.as_str()).unwrap_or("GET");

        let timeout_secs = args
            .get("timeout")
            .and_then(|v| v.as_u64())
            .unwrap_or(DEFAULT_TIMEOUT_SECS);

        let client = reqwest::Client::builder()
            .timeout(Duration::from_secs(timeout_secs))
            .build()?;

        let mut request = match method.to_uppercase().as_str() {
            "GET" => client.get(url),
            "POST" => client.post(url),
            "PUT" => client.put(url),
            "DELETE" => client.delete(url),
            "PATCH" => client.patch(url),
            "HEAD" => client.head(url),
            _ => client.get(url),
        };

        // Add headers
        if let Some(headers) = args.get("headers").and_then(|h| h.as_object()) {
            for (key, value) in headers {
                if let Some(val_str) = value.as_str() {
                    request = request.header(key, val_str);
                }
            }
        }

        // Add body
        if let Some(body) = args.get("body") {
            let body_str = if body.is_object() {
                serde_json::to_string(body)?
            } else {
                body.as_str().unwrap_or("").to_string()
            };
            request = request.body(body_str);
        }

        let response = request.send().await?;
        let status = response.status();
        let headers: HashMap<String, String> = response
            .headers()
            .iter()
            .filter_map(|(k, v)| v.to_str().ok().map(|v| (k.to_string(), v.to_string())))
            .collect();

        let content_type = response
            .headers()
            .get("content-type")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("")
            .to_lowercase();

        // Read body with size limit
        let body_bytes = response.bytes().await?;
        if body_bytes.len() > MAX_RESPONSE_SIZE {
            anyhow::bail!(
                "Response too large: {} bytes (max: {})",
                body_bytes.len(),
                MAX_RESPONSE_SIZE
            );
        }

        // Try to parse as JSON, fallback to text
        let body = if content_type.contains("application/json") {
            match serde_json::from_slice::<serde_json::Value>(&body_bytes) {
                Ok(json) => json,
                Err(_) => {
                    serde_json::Value::String(String::from_utf8_lossy(&body_bytes).to_string())
                }
            }
        } else {
            serde_json::Value::String(String::from_utf8_lossy(&body_bytes).to_string())
        };

        Ok(json!({
            "success": status.is_success(),
            "status_code": status.as_u16(),
            "status_text": status.canonical_reason(),
            "headers": headers,
            "body": body,
            "url": url,
            "method": method
        }))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_http_get() {
        let tool = HttpRequestTool::new();
        let result = tool
            .execute(json!({
                "url": "https://httpbin.org/get",
                "method": "GET"
            }))
            .await;

        // May fail if no network
        if let Ok(response) = result {
            assert_eq!(response["success"], true);
            assert_eq!(response["status_code"], 200);
        }
    }

    #[tokio::test]
    async fn test_http_post() {
        let tool = HttpRequestTool::new();
        let result = tool
            .execute(json!({
                "url": "https://httpbin.org/post",
                "method": "POST",
                "body": { "test": "data" }
            }))
            .await;

        if let Ok(response) = result {
            assert_eq!(response["success"], true);
            assert_eq!(response["status_code"], 200);
        }
    }
}