Skip to main content

agent_line/tools/
http.rs

1//! HTTP request helpers built on [ureq](https://crates.io/crates/ureq).
2//!
3//! Every verb has a plain form and a `_with_headers` variant. Custom headers
4//! are passed as a slice of `(name, value)` pairs, which keeps them ordered and
5//! allows repeated header names. All requests send a `User-Agent: agent-line`
6//! header and use a 5 second global timeout.
7//!
8//! ```no_run
9//! use agent_line::tools::{http_get_with_headers, http_post_json_with_headers};
10//!
11//! // GET with a bearer token.
12//! let body = http_get_with_headers(
13//!     "https://api.example.com/data",
14//!     &[("Authorization", "Bearer secret-token")],
15//! )?;
16//!
17//! // POST a JSON body alongside a custom header.
18//! let payload = serde_json::json!({ "name": "agent-line" });
19//! let reply = http_post_json_with_headers(
20//!     "https://api.example.com/items",
21//!     &payload,
22//!     &[("X-Request-Id", "abc-123")],
23//! )?;
24//! # Ok::<(), agent_line::StepError>(())
25//! ```
26
27use std::time::Duration;
28
29use crate::agent::StepError;
30use ureq::{self, Agent, RequestBuilder};
31
32/// Send a GET request and return the response body as a string.
33pub fn http_get(url: &str) -> Result<String, StepError> {
34    http_get_with_headers(url, &[])
35}
36
37/// Send a GET request with custom headers and return the response body as a string.
38pub fn http_get_with_headers(url: &str, headers: &[(&str, &str)]) -> Result<String, StepError> {
39    let req = apply_headers(build_agent().get(url), headers);
40    let body = req.call()?.body_mut().read_to_string()?;
41    Ok(body)
42}
43
44/// Send a POST request with a string body and return the response body.
45pub fn http_post(url: &str, body: &str) -> Result<String, StepError> {
46    http_post_with_headers(url, body, &[])
47}
48
49/// Send a POST request with a body, custom headers, and return the response body.
50pub fn http_post_with_headers(
51    url: &str,
52    body: &str,
53    headers: &[(&str, &str)],
54) -> Result<String, StepError> {
55    let req = apply_headers(build_agent().post(url), headers);
56    let response = req.send(body)?.body_mut().read_to_string()?;
57    Ok(response)
58}
59
60/// Send a POST request with a JSON body and return the response body.
61pub fn http_post_json(url: &str, body: &serde_json::Value) -> Result<String, StepError> {
62    http_post_json_with_headers(url, body, &[])
63}
64
65/// Send a POST request with a JSON body, custom headers, and return the response body.
66pub fn http_post_json_with_headers(
67    url: &str,
68    body: &serde_json::Value,
69    headers: &[(&str, &str)],
70) -> Result<String, StepError> {
71    let req = apply_headers(build_agent().post(url), headers);
72    let response = req.send_json(body)?.body_mut().read_to_string()?;
73    Ok(response)
74}
75
76fn build_agent() -> Agent {
77    Agent::config_builder()
78        .timeout_global(Some(Duration::from_secs(5)))
79        .build()
80        .into()
81}
82
83fn apply_headers<B>(mut req: RequestBuilder<B>, headers: &[(&str, &str)]) -> RequestBuilder<B> {
84    req = req.header("User-Agent", "agent-line");
85    for &(k, v) in headers {
86        req = req.header(k, v);
87    }
88
89    req
90}
91
92#[cfg(test)]
93mod tests {
94    use serde_json::json;
95
96    use super::*;
97
98    fn spawn_echo_server() -> (String, std::thread::JoinHandle<String>) {
99        use std::io::{BufRead, BufReader, Read, Write};
100        use std::net::TcpListener;
101
102        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
103        let port = listener.local_addr().unwrap().port();
104        let url = format!("http://127.0.0.1:{port}/");
105
106        let handle = std::thread::spawn(move || {
107            let (stream, _) = listener.accept().unwrap();
108            let mut reader = BufReader::new(stream.try_clone().unwrap());
109
110            // Read the request line and headers, tracking the body length.
111            let mut request = String::new();
112            let mut content_length = 0;
113            loop {
114                let mut line = String::new();
115                reader.read_line(&mut line).unwrap();
116                if let Some(value) = line.to_lowercase().strip_prefix("content-length:") {
117                    content_length = value.trim().parse().unwrap_or(0);
118                }
119                let end_of_headers = line == "\r\n" || line.is_empty();
120                request.push_str(&line);
121                if end_of_headers {
122                    break;
123                }
124            }
125
126            // Read the body, which ureq may send as a separate TCP segment.
127            if content_length > 0 {
128                let mut body = vec![0u8; content_length];
129                reader.read_exact(&mut body).unwrap();
130                request.push_str(&String::from_utf8_lossy(&body));
131            }
132
133            let mut stream = stream;
134            stream
135                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
136                .unwrap();
137            request
138        });
139
140        (url, handle)
141    }
142
143    #[test]
144    fn test_http_get_bad_url_returns_error() {
145        let result = http_get("http://localhost:1/nope");
146        assert!(result.is_err());
147    }
148
149    #[test]
150    fn test_http_post_bad_url_returns_error() {
151        let result = http_post("http://localhost:1/nope", "body content");
152        assert!(result.is_err());
153    }
154
155    #[test]
156    fn test_http_post_json_bad_url_returns_error() {
157        let body = serde_json::json!({"key": "value"});
158        let result = http_post_json("http://localhost:1/nope", &body);
159        assert!(result.is_err());
160    }
161
162    #[test]
163    fn test_get_sends_custom_headers() {
164        let (url, server) = spawn_echo_server();
165        let res = http_get_with_headers(&url, &[("X-Test", "hello")]);
166        let request = server.join().unwrap();
167        assert!(res.is_ok());
168        assert!(
169            request.to_lowercase().contains("x-test: hello"),
170            "header missing from request:\n{request}"
171        );
172    }
173
174    #[test]
175    fn test_post_sends_custom_headers() {
176        let (url, server) = spawn_echo_server();
177        let res = http_post_with_headers(&url, "test body", &[("X-Test", "bye")]);
178        let request = server.join().unwrap();
179        assert!(res.is_ok());
180        assert!(
181            request.to_lowercase().contains("x-test: bye"),
182            "header missing from request:\n{request}"
183        );
184        assert!(
185            request.contains("test body"),
186            "body missing from request:\n{request}"
187        );
188    }
189
190    #[test]
191    fn test_post_json_sends_custom_headers() {
192        let (url, server) = spawn_echo_server();
193        let body = json!({"name": "llm-agent"});
194        let res = http_post_json_with_headers(&url, &body, &[("X-Test", "cya")]);
195        let request = server.join().unwrap();
196        assert!(res.is_ok());
197        assert!(
198            request.to_lowercase().contains("x-test: cya"),
199            "header missing from request:\n{request}"
200        );
201    }
202}