use nanny_core::tool::{Tool, ToolArgs, ToolError, ToolOutput};
use std::io::Read;
use std::time::Duration;
const MAX_BODY_BYTES: u64 = 1024 * 1024;
const DEFAULT_TIMEOUT_MS: u64 = 5_000;
pub struct HttpGet {
timeout_ms: u64,
}
impl HttpGet {
pub fn new() -> Self {
Self {
timeout_ms: DEFAULT_TIMEOUT_MS,
}
}
pub fn with_timeout(timeout_ms: u64) -> Self {
Self { timeout_ms }
}
}
impl Default for HttpGet {
fn default() -> Self {
Self::new()
}
}
impl Tool for HttpGet {
fn name(&self) -> &str {
"http_get"
}
fn execute(&self, args: &ToolArgs) -> Result<ToolOutput, ToolError> {
let url = args.get("url").ok_or_else(|| ToolError::InvalidArgument {
arg: "url".to_string(),
reason: "required argument missing".to_string(),
})?;
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(ToolError::InvalidArgument {
arg: "url".to_string(),
reason: format!("must start with http:// or https://, got: {url}"),
});
}
let agent = ureq::Agent::new_with_config(
ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_millis(self.timeout_ms)))
.build(),
);
let response = agent.get(url).call().map_err(|e| match e {
ureq::Error::StatusCode(code) => ToolError::ExecutionFailed(format!("HTTP {code}")),
ureq::Error::Timeout(_) => ToolError::Timeout {
timeout_ms: self.timeout_ms,
},
other => ToolError::ExecutionFailed(other.to_string()),
})?;
let mut body = String::new();
response
.into_body()
.into_reader()
.take(MAX_BODY_BYTES)
.read_to_string(&mut body)
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
Ok(ToolOutput { content: body })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tool() -> HttpGet {
HttpGet::new()
}
#[test]
fn rejects_missing_url() {
let result = tool().execute(&ToolArgs::new());
assert!(matches!(
result,
Err(ToolError::InvalidArgument { ref arg, .. }) if arg == "url"
));
}
#[test]
fn rejects_url_without_scheme() {
let mut args = ToolArgs::new();
args.insert("url".to_string(), "example.com/path".to_string());
let result = tool().execute(&args);
assert!(matches!(
result,
Err(ToolError::InvalidArgument { ref arg, .. }) if arg == "url"
));
}
#[test]
fn rejects_ftp_scheme() {
let mut args = ToolArgs::new();
args.insert("url".to_string(), "ftp://example.com".to_string());
let result = tool().execute(&args);
assert!(matches!(
result,
Err(ToolError::InvalidArgument { ref arg, .. }) if arg == "url"
));
}
#[test]
fn accepts_http_scheme() {
let mut args = ToolArgs::new();
args.insert("url".to_string(), "http://localhost:1/test".to_string());
let result = tool().execute(&args);
assert!(!matches!(result, Err(ToolError::InvalidArgument { .. })));
}
#[test]
fn accepts_https_scheme() {
let mut args = ToolArgs::new();
args.insert("url".to_string(), "https://localhost:1/test".to_string());
let result = tool().execute(&args);
assert!(!matches!(result, Err(ToolError::InvalidArgument { .. })));
}
#[test]
fn a_server_that_never_replies_is_reported_as_a_timeout() {
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind a local port");
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
let held: Vec<_> = listener.incoming().take(1).filter_map(Result::ok).collect();
std::thread::sleep(Duration::from_secs(5));
drop(held);
});
let mut args = ToolArgs::new();
args.insert("url".to_string(), format!("http://127.0.0.1:{port}/"));
let result = HttpGet::with_timeout(250).execute(&args);
assert!(
matches!(result, Err(ToolError::Timeout { timeout_ms: 250 })),
"expected a timeout, got {result:?}"
);
}
#[test]
fn name_is_http_get() {
assert_eq!(tool().name(), "http_get");
}
}