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    /// Adds `Authorization: Bearer <token>`. An empty token adds nothing, for servers without
55    /// authentication.
56    pub fn with_bearer_token(self, token: &str) -> Self {
57        if token.is_empty() {
58            self
59        } else {
60            self.with_header("Authorization", format!("Bearer {token}"))
61        }
62    }
63
64    /// Sets the body.
65    pub fn with_body(mut self, body: impl Into<Vec<u8>>) -> Self {
66        self.body = Some(body.into());
67        self
68    }
69
70    /// Sets a JSON body and its `Content-Type`.
71    pub fn with_json(self, body: &impl Serialize) -> Result<Self, HttpError> {
72        let body = serde_json::to_vec(body).map_err(HttpError::Json)?;
73        Ok(self
74            .with_header("Content-Type", "application/json")
75            .with_body(body))
76    }
77
78    /// Sets a `multipart/form-data` body and its `Content-Type`.
79    pub fn with_multipart(self, form: Multipart) -> Self {
80        let (content_type, body) = form.into_body();
81        self.with_header("Content-Type", content_type)
82            .with_body(body)
83    }
84
85    /// Returns the value of the first header with this name, ignoring case.
86    pub fn header(&self, name: &str) -> Option<&str> {
87        find_header(&self.headers, name)
88    }
89}
90
91/// A response returned by a [`HttpClient`], with the whole body read.
92#[derive(Debug, Clone, PartialEq)]
93#[non_exhaustive]
94pub struct HttpResponse {
95    pub status: u16,
96    pub headers: Vec<(String, String)>,
97    pub body: Vec<u8>,
98}
99
100impl HttpResponse {
101    /// Creates a response without headers.
102    pub fn new(status: u16, body: impl Into<Vec<u8>>) -> Self {
103        Self {
104            status,
105            headers: Vec::new(),
106            body: body.into(),
107        }
108    }
109
110    /// Adds a header.
111    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
112        self.headers.push((name.into(), value.into()));
113        self
114    }
115
116    /// Returns `true` for a 2xx status.
117    pub fn is_success(&self) -> bool {
118        (200..300).contains(&self.status)
119    }
120
121    /// Returns the response for a 2xx status, otherwise [`HttpError::Status`] with the API's
122    /// error message.
123    pub fn error_for_status(self) -> Result<Self, HttpError> {
124        if self.is_success() {
125            Ok(self)
126        } else {
127            Err(HttpError::Status {
128                status: self.status,
129                message: self.error_message(),
130            })
131        }
132    }
133
134    /// The message of a JSON error body (`error.message`, or FastAPI's `detail` as a string,
135    /// an object with `message` or a list of validation errors), otherwise the whole body.
136    fn error_message(&self) -> String {
137        self.json::<serde_json::Value>()
138            .ok()
139            .and_then(|json| {
140                let detail = &json["detail"];
141                json["error"]["message"]
142                    .as_str()
143                    .or_else(|| detail.as_str())
144                    .or_else(|| detail["message"].as_str())
145                    .or_else(|| detail[0]["msg"].as_str())
146                    .map(str::to_string)
147            })
148            .unwrap_or_else(|| self.text())
149    }
150
151    /// Returns the value of the first header with this name, ignoring case.
152    pub fn header(&self, name: &str) -> Option<&str> {
153        find_header(&self.headers, name)
154    }
155
156    /// Parses the body as JSON.
157    pub fn json<T: DeserializeOwned>(&self) -> Result<T, HttpError> {
158        serde_json::from_slice(&self.body).map_err(HttpError::Json)
159    }
160
161    /// Returns the body as text, replacing invalid UTF-8.
162    pub fn text(&self) -> String {
163        String::from_utf8_lossy(&self.body).into_owned()
164    }
165}
166
167fn find_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
168    headers
169        .iter()
170        .find(|(key, _)| key.eq_ignore_ascii_case(name))
171        .map(|(_, value)| value.as_str())
172}
173
174/// Errors of a [`HttpClient`] and of building or reading a request.
175#[derive(Debug, Error)]
176#[non_exhaustive]
177pub enum HttpError {
178    /// The request could not be sent or the response could not be read (connection,
179    /// timeout, TLS, ...).
180    #[error("HTTP request failed: {0}")]
181    Transport(#[source] Box<dyn std::error::Error + Send + Sync>),
182    /// A JSON body could not be written or read.
183    #[error("invalid JSON body: {0}")]
184    Json(serde_json::Error),
185    /// The server answered with an error status, see [`HttpResponse::error_for_status`].
186    #[error("HTTP status {status}: {message}")]
187    Status { status: u16, message: String },
188    /// The bytes of a [`Media`](crate::message::Media) are not valid base64.
189    #[error("invalid base64 media: {0}")]
190    InvalidMedia(String),
191}
192
193impl HttpError {
194    /// Creates a [`HttpError::Transport`] from any error.
195    pub fn transport(err: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
196        Self::Transport(err.into())
197    }
198}
199
200/// Sends HTTP requests.
201///
202/// Crates such as `agentai-image` send their requests through this trait instead of a
203/// particular HTTP client. [`ReqwestClient`] (feature `reqwest`) implements it.
204///
205/// The returned future is `Send`. Clients whose futures are not `Send` (for example in the
206/// browser) will need a relaxed bound, which comes with WebAssembly support.
207#[async_trait]
208pub trait HttpClient: Send + Sync + 'static {
209    /// Sends the request and reads the whole response. A response with an error status is
210    /// still `Ok`; only failures to send or read are errors.
211    async fn send(&self, request: HttpRequest) -> Result<HttpResponse, HttpError>;
212}
213
214/// A `multipart/form-data` body, built in memory.
215///
216/// ```rust
217/// # use agentai_core::{HttpRequest, Multipart};
218/// let request = HttpRequest::post("https://example.com/upload").with_multipart(
219///     Multipart::new()
220///         .with_text("prompt", "a cat")
221///         .with_file("image", "cat.png", "image/png", vec![0x89, b'P', b'N', b'G']),
222/// );
223/// ```
224#[derive(Debug, Clone, Default, PartialEq)]
225pub struct Multipart {
226    parts: Vec<FormPart>,
227}
228
229#[derive(Debug, Clone, PartialEq)]
230struct FormPart {
231    name: String,
232    file: Option<(String, String)>,
233    data: Vec<u8>,
234}
235
236impl Multipart {
237    /// Creates an empty form.
238    pub fn new() -> Self {
239        Self::default()
240    }
241
242    /// Adds a text field.
243    pub fn with_text(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
244        self.parts.push(FormPart {
245            name: name.into(),
246            file: None,
247            data: value.into().into_bytes(),
248        });
249        self
250    }
251
252    /// Adds a file field.
253    pub fn with_file(
254        mut self,
255        name: impl Into<String>,
256        filename: impl Into<String>,
257        content_type: impl Into<String>,
258        data: impl Into<Vec<u8>>,
259    ) -> Self {
260        self.parts.push(FormPart {
261            name: name.into(),
262            file: Some((filename.into(), content_type.into())),
263            data: data.into(),
264        });
265        self
266    }
267
268    /// Returns the `Content-Type` header value and the body.
269    fn into_body(self) -> (String, Vec<u8>) {
270        let boundary = self.boundary();
271        let mut body = Vec::new();
272        for part in self.parts {
273            body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
274            let name = escape(&part.name);
275            match part.file {
276                Some((filename, content_type)) => body.extend_from_slice(
277                    format!(
278                        "Content-Disposition: form-data; name=\"{name}\"; filename=\"{}\"\r\n\
279                         Content-Type: {content_type}\r\n\r\n",
280                        escape(&filename)
281                    )
282                    .as_bytes(),
283                ),
284                None => body.extend_from_slice(
285                    format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(),
286                ),
287            }
288            body.extend_from_slice(&part.data);
289            body.extend_from_slice(b"\r\n");
290        }
291        body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
292        (format!("multipart/form-data; boundary={boundary}"), body)
293    }
294
295    /// A boundary that appears in no part. Chosen without randomness or a clock, so it works
296    /// in every environment.
297    fn boundary(&self) -> String {
298        (0u64..)
299            .map(|n| format!("agentai-boundary-{n:016x}"))
300            .find(|boundary| {
301                !self
302                    .parts
303                    .iter()
304                    .any(|part| contains(&part.data, boundary.as_bytes()))
305            })
306            .expect("some boundary is not in the data")
307    }
308}
309
310fn contains(haystack: &[u8], needle: &[u8]) -> bool {
311    haystack
312        .windows(needle.len())
313        .any(|window| window == needle)
314}
315
316/// Quotes and line breaks would end the header value early.
317fn escape(value: &str) -> String {
318    value
319        .replace('"', "%22")
320        .replace('\r', "%0D")
321        .replace('\n', "%0A")
322}
323
324#[cfg(feature = "reqwest")]
325mod reqwest;
326#[cfg(feature = "reqwest")]
327pub use reqwest::ReqwestClient;
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn error_for_status_reads_every_error_shape() {
335        let message = |body: &str| match HttpResponse::new(400, body).error_for_status() {
336            Err(HttpError::Status {
337                status: 400,
338                message,
339            }) => message,
340            other => panic!("{other:?}"),
341        };
342        assert_eq!(message(r#"{"error": {"message": "openai"}}"#), "openai");
343        assert_eq!(message(r#"{"detail": "omlx"}"#), "omlx");
344        assert_eq!(message(r#"{"detail": {"message": "eleven"}}"#), "eleven");
345        assert_eq!(
346            message(r#"{"detail": [{"msg": "validation"}]}"#),
347            "validation"
348        );
349        assert_eq!(message(r#"{"other":1}"#), r#"{"other":1}"#);
350        assert_eq!(message("bad gateway"), "bad gateway");
351        assert!(HttpResponse::new(204, "").error_for_status().is_ok());
352    }
353
354    #[test]
355    fn multipart_body_has_the_fields_and_files_between_boundaries() {
356        let request = HttpRequest::post("u").with_multipart(
357            Multipart::new().with_text("prompt", "a cat").with_file(
358                "image[]",
359                "a.png",
360                "image/png",
361                b"PNG".to_vec(),
362            ),
363        );
364        let boundary = "agentai-boundary-0000000000000000";
365        assert_eq!(
366            request.header("content-type"),
367            Some(format!("multipart/form-data; boundary={boundary}").as_str())
368        );
369        let expected = format!(
370            "--{boundary}\r\n\
371             Content-Disposition: form-data; name=\"prompt\"\r\n\r\n\
372             a cat\r\n\
373             --{boundary}\r\n\
374             Content-Disposition: form-data; name=\"image[]\"; filename=\"a.png\"\r\n\
375             Content-Type: image/png\r\n\r\n\
376             PNG\r\n\
377             --{boundary}--\r\n"
378        );
379        assert_eq!(String::from_utf8(request.body.unwrap()).unwrap(), expected);
380    }
381
382    #[test]
383    fn multipart_boundary_never_appears_in_the_data() {
384        let data = "agentai-boundary-0000000000000000";
385        let request =
386            HttpRequest::post("u").with_multipart(Multipart::new().with_text("prompt", data));
387        let content_type = request.header("Content-Type").unwrap();
388        assert!(content_type.ends_with("agentai-boundary-0000000000000001"));
389    }
390}