use super::Tool;
use crate::core::context::WorkflowContext;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use reqwest::redirect;
use serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::time::{Duration, Instant};
pub struct HttpRequest;
#[async_trait]
impl Tool for HttpRequest {
fn name(&self) -> &str {
"http_request"
}
fn schema(&self) -> Option<Value> {
Some(json!({
"type": "function",
"function": {
"name": "http_request",
"description": "Make an HTTP request (httpx-style). Supports all HTTP methods, query params, JSON/form/multipart body, auth, timeout, cookies, and redirect control.",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Request URL (required)"
},
"method": {
"type": "string",
"description": "HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Default: GET"
},
"params": {
"type": "object",
"description": "Query parameters as JSON object, appended to URL"
},
"headers": {
"type": "object",
"description": "Custom request headers as JSON object"
},
"json": {
"type": "object",
"description": "JSON body (auto-sets Content-Type: application/json)"
},
"data": {
"type": "object",
"description": "Form data as JSON object (URL-encoded, Content-Type: application/x-www-form-urlencoded)"
},
"files": {
"type": "object",
"description": "Multipart file upload: {field_name: file_path}"
},
"content": {
"type": "string",
"description": "Raw body content string"
},
"timeout": {
"type": "number",
"description": "Request timeout in seconds"
},
"auth": {
"type": "string",
"description": "Auth: 'Bearer <token>' for bearer auth, 'user:pass' for basic auth"
},
"follow_redirects": {
"type": "boolean",
"description": "Follow redirects. Default: true"
},
"cookies": {
"type": "object",
"description": "Cookies as JSON object {name: value}"
}
},
"required": ["url"]
}
}
}))
}
async fn execute(
&self,
params: &HashMap<String, String>,
_context: &WorkflowContext,
) -> Result<Option<Value>> {
let get = |key: &str| -> Option<&String> {
params.get(key).filter(|v| {
let t = v.trim_matches('"');
!t.is_empty() && t != "null"
})
};
let raw_url = params
.get("url")
.ok_or_else(|| anyhow!("http_request() requires 'url' parameter"))?
.trim_matches('"');
let url = if let Some(params_json) = get("params") {
append_query_params(raw_url, params_json)?
} else {
raw_url.to_string()
};
let method = get("method")
.map(|s| s.trim_matches('"').to_uppercase())
.unwrap_or_else(|| "GET".to_string());
let follow = get("follow_redirects")
.map(|s| {
let v = s.trim_matches('"');
v != "false" && v != "0"
})
.unwrap_or(true);
let mut client_builder = reqwest::Client::builder();
if follow {
client_builder = client_builder.redirect(redirect::Policy::limited(10));
} else {
client_builder = client_builder.redirect(redirect::Policy::none());
}
if let Some(timeout_str) = get("timeout") {
if let Ok(secs) = timeout_str.trim_matches('"').parse::<f64>() {
client_builder = client_builder.timeout(Duration::from_secs_f64(secs));
}
}
let client = client_builder.build()?;
let mut builder = match method.as_str() {
"POST" => client.post(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
"PATCH" => client.patch(&url),
"HEAD" => client.head(&url),
"OPTIONS" => client.request(reqwest::Method::OPTIONS, &url),
_ => client.get(&url),
};
if let Some(auth_str) = get("auth") {
let auth = auth_str.trim_matches('"');
if let Some(token) = auth
.strip_prefix("Bearer ")
.or(auth.strip_prefix("bearer "))
{
builder = builder.bearer_auth(token);
} else if let Some((user, pass)) = auth.split_once(':') {
builder = builder.basic_auth(user, Some(pass));
} else {
builder = builder.bearer_auth(auth);
}
}
if let Some(headers_json) = get("headers") {
if let Ok(headers_map) = serde_json::from_str::<HashMap<String, String>>(headers_json) {
for (key, value) in headers_map {
builder = builder.header(&key, &value);
}
}
}
if let Some(cookies_json) = get("cookies") {
if let Ok(cookies_map) = serde_json::from_str::<HashMap<String, String>>(cookies_json) {
let cookie_str: String = cookies_map
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("; ");
if !cookie_str.is_empty() {
builder = builder.header("Cookie", cookie_str);
}
}
}
if let Some(json_str) = get("json") {
builder = builder.header("Content-Type", "application/json");
builder = builder.body(json_str.clone());
} else if let Some(data_str) = get("data") {
if let Ok(form_map) = serde_json::from_str::<HashMap<String, String>>(data_str) {
builder = builder.form(&form_map);
} else {
builder = builder.header("Content-Type", "application/x-www-form-urlencoded");
builder = builder.body(data_str.clone());
}
} else if let Some(files_str) = get("files") {
if let Ok(files_map) = serde_json::from_str::<HashMap<String, String>>(files_str) {
let mut form = reqwest::multipart::Form::new();
for (field_name, file_path) in &files_map {
let path = std::path::Path::new(file_path);
let file_bytes = tokio::fs::read(path)
.await
.map_err(|e| anyhow!("Failed to read file '{}': {}", file_path, e))?;
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "upload".to_string());
let part = reqwest::multipart::Part::bytes(file_bytes).file_name(filename);
form = form.part(field_name.clone(), part);
}
builder = builder.multipart(form);
}
} else if let Some(content) = get("content") {
builder = builder.body(content.trim_matches('"').to_string());
}
let start = Instant::now();
let res = builder.send().await?;
let elapsed = start.elapsed().as_secs_f64();
let status = res.status().as_u16();
let final_url = res.url().to_string();
let resp_headers: Map<String, Value> = res
.headers()
.iter()
.map(|(k, v)| (k.as_str().to_string(), json!(v.to_str().unwrap_or(""))))
.collect();
let content_type = resp_headers
.get("content-type")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let text = res.text().await?;
let json_value: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
Ok(Some(json!({
"status_code": status,
"headers": resp_headers,
"json": json_value,
"text": text,
"url": final_url,
"is_success": (200..300).contains(&status),
"elapsed": elapsed,
"content_type": content_type
})))
}
}
fn append_query_params(base_url: &str, params_json: &str) -> Result<String> {
let params: HashMap<String, Value> =
serde_json::from_str(params_json).map_err(|e| anyhow!("Invalid params JSON: {}", e))?;
if params.is_empty() {
return Ok(base_url.to_string());
}
let mut url =
reqwest::Url::parse(base_url).map_err(|e| anyhow!("Invalid URL '{}': {}", base_url, e))?;
{
let mut query_pairs = url.query_pairs_mut();
for (key, value) in ¶ms {
let v = match value {
Value::String(s) => s.clone(),
Value::Null => continue,
other => other.to_string(),
};
query_pairs.append_pair(key, &v);
}
}
Ok(url.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_append_query_params_basic() {
let url = append_query_params("https://example.com/api", r#"{"page": 1, "limit": "10"}"#)
.unwrap();
let parsed = reqwest::Url::parse(&url).unwrap();
let pairs: HashMap<String, String> = parsed.query_pairs().into_owned().collect();
assert_eq!(pairs.get("page").unwrap(), "1");
assert_eq!(pairs.get("limit").unwrap(), "10");
}
#[test]
fn test_append_query_params_preserves_existing() {
let url = append_query_params("https://example.com/api?existing=yes", r#"{"new": "val"}"#)
.unwrap();
let parsed = reqwest::Url::parse(&url).unwrap();
let pairs: Vec<(String, String)> = parsed.query_pairs().into_owned().collect();
assert!(pairs.iter().any(|(k, v)| k == "existing" && v == "yes"));
assert!(pairs.iter().any(|(k, v)| k == "new" && v == "val"));
}
#[test]
fn test_append_query_params_skips_null() {
let url = append_query_params(
"https://example.com/api",
r#"{"keep": "yes", "skip": null}"#,
)
.unwrap();
assert!(url.contains("keep=yes"));
assert!(!url.contains("skip"));
}
#[test]
fn test_append_query_params_empty() {
let url = append_query_params("https://example.com/api", r#"{}"#).unwrap();
assert_eq!(url, "https://example.com/api");
}
#[test]
fn test_append_query_params_invalid_json() {
let result = append_query_params("https://example.com", "not json");
assert!(result.is_err());
}
}