Skip to main content

agentai_core/http/
mod.rs

1//! HTTP client abstraction.
2
3use async_trait::async_trait;
4use serde::Serialize;
5use serde::de::DeserializeOwned;
6use thiserror::Error;
7
8/// HTTP method of a [`HttpRequest`].
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10#[non_exhaustive]
11pub enum Method {
12    Get,
13    Post,
14}
15
16/// A request sent with a [`HttpClient`]. The body is always plain bytes, so a client only
17/// has to send them.
18#[derive(Debug, Clone, PartialEq)]
19#[non_exhaustive]
20pub struct HttpRequest {
21    pub method: Method,
22    pub url: String,
23    pub headers: Vec<(String, String)>,
24    pub body: Option<Vec<u8>>,
25}
26
27impl HttpRequest {
28    /// Creates a request without headers and body.
29    pub fn new(method: Method, url: impl Into<String>) -> Self {
30        Self {
31            method,
32            url: url.into(),
33            headers: Vec::new(),
34            body: None,
35        }
36    }
37
38    /// Creates a `GET` request.
39    pub fn get(url: impl Into<String>) -> Self {
40        Self::new(Method::Get, url)
41    }
42
43    /// Creates a `POST` request.
44    pub fn post(url: impl Into<String>) -> Self {
45        Self::new(Method::Post, url)
46    }
47
48    /// Adds a header.
49    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
50        self.headers.push((name.into(), value.into()));
51        self
52    }
53
54    /// Sets the body.
55    pub fn with_body(mut self, body: impl Into<Vec<u8>>) -> Self {
56        self.body = Some(body.into());
57        self
58    }
59
60    /// Sets a JSON body and its `Content-Type`.
61    pub fn with_json(self, body: &impl Serialize) -> Result<Self, HttpError> {
62        let body = serde_json::to_vec(body).map_err(HttpError::Json)?;
63        Ok(self
64            .with_header("Content-Type", "application/json")
65            .with_body(body))
66    }
67
68    /// Sets a `multipart/form-data` body and its `Content-Type`.
69    pub fn with_multipart(self, form: Multipart) -> Self {
70        let (content_type, body) = form.into_body();
71        self.with_header("Content-Type", content_type)
72            .with_body(body)
73    }
74
75    /// Returns the value of the first header with this name, ignoring case.
76    pub fn header(&self, name: &str) -> Option<&str> {
77        find_header(&self.headers, name)
78    }
79}
80
81/// A response returned by a [`HttpClient`], with the whole body read.
82#[derive(Debug, Clone, PartialEq)]
83#[non_exhaustive]
84pub struct HttpResponse {
85    pub status: u16,
86    pub headers: Vec<(String, String)>,
87    pub body: Vec<u8>,
88}
89
90impl HttpResponse {
91    /// Creates a response without headers.
92    pub fn new(status: u16, body: impl Into<Vec<u8>>) -> Self {
93        Self {
94            status,
95            headers: Vec::new(),
96            body: body.into(),
97        }
98    }
99
100    /// Adds a header.
101    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
102        self.headers.push((name.into(), value.into()));
103        self
104    }
105
106    /// Returns `true` for a 2xx status.
107    pub fn is_success(&self) -> bool {
108        (200..300).contains(&self.status)
109    }
110
111    /// Returns the value of the first header with this name, ignoring case.
112    pub fn header(&self, name: &str) -> Option<&str> {
113        find_header(&self.headers, name)
114    }
115
116    /// Parses the body as JSON.
117    pub fn json<T: DeserializeOwned>(&self) -> Result<T, HttpError> {
118        serde_json::from_slice(&self.body).map_err(HttpError::Json)
119    }
120
121    /// Returns the body as text, replacing invalid UTF-8.
122    pub fn text(&self) -> String {
123        String::from_utf8_lossy(&self.body).into_owned()
124    }
125}
126
127fn find_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
128    headers
129        .iter()
130        .find(|(key, _)| key.eq_ignore_ascii_case(name))
131        .map(|(_, value)| value.as_str())
132}
133
134/// Errors of a [`HttpClient`] and of building or reading a request.
135#[derive(Debug, Error)]
136#[non_exhaustive]
137pub enum HttpError {
138    /// The request could not be sent or the response could not be read (connection,
139    /// timeout, TLS, ...).
140    #[error("HTTP request failed: {0}")]
141    Transport(#[source] Box<dyn std::error::Error + Send + Sync>),
142    /// A JSON body could not be written or read.
143    #[error("invalid JSON body: {0}")]
144    Json(serde_json::Error),
145}
146
147impl HttpError {
148    /// Creates a [`HttpError::Transport`] from any error.
149    pub fn transport(err: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
150        Self::Transport(err.into())
151    }
152}
153
154/// Sends HTTP requests.
155///
156/// Crates such as `agentai-image` send their requests through this trait instead of a
157/// particular HTTP client. [`ReqwestClient`] (feature `reqwest`) implements it.
158///
159/// The returned future is `Send`. Clients whose futures are not `Send` (for example in the
160/// browser) will need a relaxed bound, which comes with WebAssembly support.
161#[async_trait]
162pub trait HttpClient: Send + Sync + 'static {
163    /// Sends the request and reads the whole response. A response with an error status is
164    /// still `Ok`; only failures to send or read are errors.
165    async fn send(&self, request: HttpRequest) -> Result<HttpResponse, HttpError>;
166}
167
168/// A `multipart/form-data` body, built in memory.
169///
170/// ```rust
171/// # use agentai_core::{HttpRequest, Multipart};
172/// let request = HttpRequest::post("https://example.com/upload").with_multipart(
173///     Multipart::new()
174///         .with_text("prompt", "a cat")
175///         .with_file("image", "cat.png", "image/png", vec![0x89, b'P', b'N', b'G']),
176/// );
177/// ```
178#[derive(Debug, Clone, Default, PartialEq)]
179pub struct Multipart {
180    parts: Vec<FormPart>,
181}
182
183#[derive(Debug, Clone, PartialEq)]
184struct FormPart {
185    name: String,
186    file: Option<(String, String)>,
187    data: Vec<u8>,
188}
189
190impl Multipart {
191    /// Creates an empty form.
192    pub fn new() -> Self {
193        Self::default()
194    }
195
196    /// Adds a text field.
197    pub fn with_text(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
198        self.parts.push(FormPart {
199            name: name.into(),
200            file: None,
201            data: value.into().into_bytes(),
202        });
203        self
204    }
205
206    /// Adds a file field.
207    pub fn with_file(
208        mut self,
209        name: impl Into<String>,
210        filename: impl Into<String>,
211        content_type: impl Into<String>,
212        data: impl Into<Vec<u8>>,
213    ) -> Self {
214        self.parts.push(FormPart {
215            name: name.into(),
216            file: Some((filename.into(), content_type.into())),
217            data: data.into(),
218        });
219        self
220    }
221
222    /// Returns the `Content-Type` header value and the body.
223    fn into_body(self) -> (String, Vec<u8>) {
224        let boundary = self.boundary();
225        let mut body = Vec::new();
226        for part in self.parts {
227            body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
228            let name = escape(&part.name);
229            match part.file {
230                Some((filename, content_type)) => body.extend_from_slice(
231                    format!(
232                        "Content-Disposition: form-data; name=\"{name}\"; filename=\"{}\"\r\n\
233                         Content-Type: {content_type}\r\n\r\n",
234                        escape(&filename)
235                    )
236                    .as_bytes(),
237                ),
238                None => body.extend_from_slice(
239                    format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(),
240                ),
241            }
242            body.extend_from_slice(&part.data);
243            body.extend_from_slice(b"\r\n");
244        }
245        body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
246        (format!("multipart/form-data; boundary={boundary}"), body)
247    }
248
249    /// A boundary that appears in no part. Chosen without randomness or a clock, so it works
250    /// in every environment.
251    fn boundary(&self) -> String {
252        (0u64..)
253            .map(|n| format!("agentai-boundary-{n:016x}"))
254            .find(|boundary| {
255                !self
256                    .parts
257                    .iter()
258                    .any(|part| contains(&part.data, boundary.as_bytes()))
259            })
260            .expect("some boundary is not in the data")
261    }
262}
263
264fn contains(haystack: &[u8], needle: &[u8]) -> bool {
265    haystack
266        .windows(needle.len())
267        .any(|window| window == needle)
268}
269
270/// Quotes and line breaks would end the header value early.
271fn escape(value: &str) -> String {
272    value
273        .replace('"', "%22")
274        .replace('\r', "%0D")
275        .replace('\n', "%0A")
276}
277
278#[cfg(feature = "reqwest")]
279mod reqwest;
280#[cfg(feature = "reqwest")]
281pub use reqwest::ReqwestClient;
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn multipart_body_has_the_fields_and_files_between_boundaries() {
289        let request = HttpRequest::post("u").with_multipart(
290            Multipart::new().with_text("prompt", "a cat").with_file(
291                "image[]",
292                "a.png",
293                "image/png",
294                b"PNG".to_vec(),
295            ),
296        );
297        let boundary = "agentai-boundary-0000000000000000";
298        assert_eq!(
299            request.header("content-type"),
300            Some(format!("multipart/form-data; boundary={boundary}").as_str())
301        );
302        let expected = format!(
303            "--{boundary}\r\n\
304             Content-Disposition: form-data; name=\"prompt\"\r\n\r\n\
305             a cat\r\n\
306             --{boundary}\r\n\
307             Content-Disposition: form-data; name=\"image[]\"; filename=\"a.png\"\r\n\
308             Content-Type: image/png\r\n\r\n\
309             PNG\r\n\
310             --{boundary}--\r\n"
311        );
312        assert_eq!(String::from_utf8(request.body.unwrap()).unwrap(), expected);
313    }
314
315    #[test]
316    fn multipart_boundary_never_appears_in_the_data() {
317        let data = "agentai-boundary-0000000000000000";
318        let request =
319            HttpRequest::post("u").with_multipart(Multipart::new().with_text("prompt", data));
320        let content_type = request.header("Content-Type").unwrap();
321        assert!(content_type.ends_with("agentai-boundary-0000000000000001"));
322    }
323}