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_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 pub fn with_body(mut self, body: impl Into<Vec<u8>>) -> Self {
66 self.body = Some(body.into());
67 self
68 }
69
70 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 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 pub fn header(&self, name: &str) -> Option<&str> {
87 find_header(&self.headers, name)
88 }
89}
90
91#[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 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 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 pub fn is_success(&self) -> bool {
118 (200..300).contains(&self.status)
119 }
120
121 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 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 pub fn header(&self, name: &str) -> Option<&str> {
153 find_header(&self.headers, name)
154 }
155
156 pub fn json<T: DeserializeOwned>(&self) -> Result<T, HttpError> {
158 serde_json::from_slice(&self.body).map_err(HttpError::Json)
159 }
160
161 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#[derive(Debug, Error)]
176#[non_exhaustive]
177pub enum HttpError {
178 #[error("HTTP request failed: {0}")]
181 Transport(#[source] Box<dyn std::error::Error + Send + Sync>),
182 #[error("invalid JSON body: {0}")]
184 Json(serde_json::Error),
185 #[error("HTTP status {status}: {message}")]
187 Status { status: u16, message: String },
188 #[error("invalid base64 media: {0}")]
190 InvalidMedia(String),
191}
192
193impl HttpError {
194 pub fn transport(err: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
196 Self::Transport(err.into())
197 }
198}
199
200#[async_trait]
208pub trait HttpClient: Send + Sync + 'static {
209 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, HttpError>;
212}
213
214#[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 pub fn new() -> Self {
239 Self::default()
240 }
241
242 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 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 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 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
316fn 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}