use async_trait::async_trait;
use serde::Serialize;
use serde::de::DeserializeOwned;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Method {
Get,
Post,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct HttpRequest {
pub method: Method,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Option<Vec<u8>>,
}
impl HttpRequest {
pub fn new(method: Method, url: impl Into<String>) -> Self {
Self {
method,
url: url.into(),
headers: Vec::new(),
body: None,
}
}
pub fn get(url: impl Into<String>) -> Self {
Self::new(Method::Get, url)
}
pub fn post(url: impl Into<String>) -> Self {
Self::new(Method::Post, url)
}
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push((name.into(), value.into()));
self
}
pub fn with_body(mut self, body: impl Into<Vec<u8>>) -> Self {
self.body = Some(body.into());
self
}
pub fn with_json(self, body: &impl Serialize) -> Result<Self, HttpError> {
let body = serde_json::to_vec(body).map_err(HttpError::Json)?;
Ok(self
.with_header("Content-Type", "application/json")
.with_body(body))
}
pub fn with_multipart(self, form: Multipart) -> Self {
let (content_type, body) = form.into_body();
self.with_header("Content-Type", content_type)
.with_body(body)
}
pub fn header(&self, name: &str) -> Option<&str> {
find_header(&self.headers, name)
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct HttpResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl HttpResponse {
pub fn new(status: u16, body: impl Into<Vec<u8>>) -> Self {
Self {
status,
headers: Vec::new(),
body: body.into(),
}
}
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push((name.into(), value.into()));
self
}
pub fn is_success(&self) -> bool {
(200..300).contains(&self.status)
}
pub fn header(&self, name: &str) -> Option<&str> {
find_header(&self.headers, name)
}
pub fn json<T: DeserializeOwned>(&self) -> Result<T, HttpError> {
serde_json::from_slice(&self.body).map_err(HttpError::Json)
}
pub fn text(&self) -> String {
String::from_utf8_lossy(&self.body).into_owned()
}
}
fn find_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum HttpError {
#[error("HTTP request failed: {0}")]
Transport(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error("invalid JSON body: {0}")]
Json(serde_json::Error),
}
impl HttpError {
pub fn transport(err: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
Self::Transport(err.into())
}
}
#[async_trait]
pub trait HttpClient: Send + Sync + 'static {
async fn send(&self, request: HttpRequest) -> Result<HttpResponse, HttpError>;
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Multipart {
parts: Vec<FormPart>,
}
#[derive(Debug, Clone, PartialEq)]
struct FormPart {
name: String,
file: Option<(String, String)>,
data: Vec<u8>,
}
impl Multipart {
pub fn new() -> Self {
Self::default()
}
pub fn with_text(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.parts.push(FormPart {
name: name.into(),
file: None,
data: value.into().into_bytes(),
});
self
}
pub fn with_file(
mut self,
name: impl Into<String>,
filename: impl Into<String>,
content_type: impl Into<String>,
data: impl Into<Vec<u8>>,
) -> Self {
self.parts.push(FormPart {
name: name.into(),
file: Some((filename.into(), content_type.into())),
data: data.into(),
});
self
}
fn into_body(self) -> (String, Vec<u8>) {
let boundary = self.boundary();
let mut body = Vec::new();
for part in self.parts {
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
let name = escape(&part.name);
match part.file {
Some((filename, content_type)) => body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"{name}\"; filename=\"{}\"\r\n\
Content-Type: {content_type}\r\n\r\n",
escape(&filename)
)
.as_bytes(),
),
None => body.extend_from_slice(
format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(),
),
}
body.extend_from_slice(&part.data);
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
(format!("multipart/form-data; boundary={boundary}"), body)
}
fn boundary(&self) -> String {
(0u64..)
.map(|n| format!("agentai-boundary-{n:016x}"))
.find(|boundary| {
!self
.parts
.iter()
.any(|part| contains(&part.data, boundary.as_bytes()))
})
.expect("some boundary is not in the data")
}
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack
.windows(needle.len())
.any(|window| window == needle)
}
fn escape(value: &str) -> String {
value
.replace('"', "%22")
.replace('\r', "%0D")
.replace('\n', "%0A")
}
#[cfg(feature = "reqwest")]
mod reqwest;
#[cfg(feature = "reqwest")]
pub use reqwest::ReqwestClient;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn multipart_body_has_the_fields_and_files_between_boundaries() {
let request = HttpRequest::post("u").with_multipart(
Multipart::new().with_text("prompt", "a cat").with_file(
"image[]",
"a.png",
"image/png",
b"PNG".to_vec(),
),
);
let boundary = "agentai-boundary-0000000000000000";
assert_eq!(
request.header("content-type"),
Some(format!("multipart/form-data; boundary={boundary}").as_str())
);
let expected = format!(
"--{boundary}\r\n\
Content-Disposition: form-data; name=\"prompt\"\r\n\r\n\
a cat\r\n\
--{boundary}\r\n\
Content-Disposition: form-data; name=\"image[]\"; filename=\"a.png\"\r\n\
Content-Type: image/png\r\n\r\n\
PNG\r\n\
--{boundary}--\r\n"
);
assert_eq!(String::from_utf8(request.body.unwrap()).unwrap(), expected);
}
#[test]
fn multipart_boundary_never_appears_in_the_data() {
let data = "agentai-boundary-0000000000000000";
let request =
HttpRequest::post("u").with_multipart(Multipart::new().with_text("prompt", data));
let content_type = request.header("Content-Type").unwrap();
assert!(content_type.ends_with("agentai-boundary-0000000000000001"));
}
}