1use async_trait::async_trait;
4use serde::Serialize;
5use serde::de::DeserializeOwned;
6use thiserror::Error;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10#[non_exhaustive]
11pub enum Method {
12 Get,
13 Post,
14}
15
16#[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 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 pub fn get(url: impl Into<String>) -> Self {
40 Self::new(Method::Get, url)
41 }
42
43 pub fn post(url: impl Into<String>) -> Self {
45 Self::new(Method::Post, url)
46 }
47
48 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 pub fn with_body(mut self, body: impl Into<Vec<u8>>) -> Self {
56 self.body = Some(body.into());
57 self
58 }
59
60 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 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 pub fn header(&self, name: &str) -> Option<&str> {
77 find_header(&self.headers, name)
78 }
79}
80
81#[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 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 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 pub fn is_success(&self) -> bool {
108 (200..300).contains(&self.status)
109 }
110
111 pub fn header(&self, name: &str) -> Option<&str> {
113 find_header(&self.headers, name)
114 }
115
116 pub fn json<T: DeserializeOwned>(&self) -> Result<T, HttpError> {
118 serde_json::from_slice(&self.body).map_err(HttpError::Json)
119 }
120
121 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#[derive(Debug, Error)]
136#[non_exhaustive]
137pub enum HttpError {
138 #[error("HTTP request failed: {0}")]
141 Transport(#[source] Box<dyn std::error::Error + Send + Sync>),
142 #[error("invalid JSON body: {0}")]
144 Json(serde_json::Error),
145}
146
147impl HttpError {
148 pub fn transport(err: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
150 Self::Transport(err.into())
151 }
152}
153
154#[async_trait]
162pub trait HttpClient: Send + Sync + 'static {
163 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, HttpError>;
166}
167
168#[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 pub fn new() -> Self {
193 Self::default()
194 }
195
196 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 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 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 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
270fn 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}