use anyhow::{Context, Result};
use serde::Serialize;
use std::collections::HashMap;
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
net::TcpStream,
};
use url::Url;
#[derive(Debug, Clone)]
pub enum HttpMethod {
GET,
POST,
PUT,
PATCH,
DELETE,
HEAD,
}
#[derive(Debug, thiserror::Error)]
pub enum HttpError {
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[error("Connection failed: {0}")]
ConnectionFailed(String),
#[error("TLS error: {0}")]
TlsError(String),
#[error("Request failed: {0}")]
RequestFailed(String),
#[error("Response parsing error: {0}")]
ResponseParseError(String),
}
pub struct HttpClient;
impl HttpClient {
pub async fn fetch<T: Serialize>(
method: HttpMethod,
url: String,
headers: Option<std::collections::HashMap<String, String>>,
body: Option<T>,
) -> Result<Response, HttpError> {
let parsed = Url::parse(&url).map_err(|e| HttpError::InvalidUrl(e.to_string()))?;
let scheme = parsed.scheme();
let host = parsed
.host_str()
.ok_or_else(|| HttpError::InvalidUrl("Missing host".to_string()))?;
let port = parsed
.port_or_known_default()
.ok_or_else(|| HttpError::InvalidUrl("Missing port".to_string()))?;
let path = parsed.path();
let full_path = match parsed.query() {
Some(query) => format!("{}?{}", path, query),
None => path.to_string(),
};
match scheme {
#[cfg(feature = "tls")]
"https" => {
let conn = TcpStream::connect((host, port)).await.map_err(|e| {
HttpError::ConnectionFailed(format!("Failed to connect to {}: {}", host, e))
})?;
let mut tls_connector = native_tls::TlsConnector::builder();
tls_connector.danger_accept_invalid_certs(true);
let tls_connector = tls_connector
.build()
.map_err(|e| HttpError::TlsError(format!("TLS init failed: {}", e)))?;
let connector = tokio_native_tls::TlsConnector::from(tls_connector);
let stream = connector
.connect(host, conn)
.await
.map_err(|e| HttpError::TlsError(format!("TLS handshake failed: {}", e)))?;
Self::make_request(stream, method, host, &full_path, body, headers).await
}
#[cfg(not(feature = "tls"))]
"https" => Err(HttpError::TlsError(
"TLS support not enabled. Enable 'tls' feature".to_string(),
)),
"http" => {
let stream = TcpStream::connect((host, port)).await.map_err(|e| {
HttpError::ConnectionFailed(format!("Failed to connect to {}: {}", host, e))
})?;
Self::make_request(stream, method, host, &full_path, body, headers).await
}
_ => Err(HttpError::InvalidUrl(format!(
"Unsupported scheme: {}",
scheme
))),
}
}
async fn make_request<T, S>(
mut stream: T,
method: HttpMethod,
host: &str,
full_path: &str,
body: Option<S>,
headers: Option<std::collections::HashMap<String, String>>,
) -> Result<Response, HttpError>
where
T: AsyncRead + AsyncWrite + Unpin,
S: Serialize,
{
let request = Self::build_request(method, host, full_path, body, headers)?;
stream
.write_all(request.as_bytes())
.await
.map_err(|e| HttpError::RequestFailed(format!("Failed to write request: {}", e)))?;
let response_data = Self::read_response(&mut stream).await?;
let response_str = String::from_utf8_lossy(&response_data);
Response::parse(&response_str).map_err(|e| HttpError::ResponseParseError(e.to_string()))
}
fn build_request<S: Serialize>(
method: HttpMethod,
host: &str,
full_path: &str,
body: Option<S>,
headers: Option<std::collections::HashMap<String, String>>,
) -> Result<String, HttpError> {
match method {
HttpMethod::GET => {
let mut request = format!(
"GET {} HTTP/1.1\r\n\
Host: {}\r\n\
User-Agent: rumbo-http-client/0.1.0\r\n\
Connection: close\r\n",
full_path, host
);
if let Some(header_map) = headers {
for (key, value) in header_map {
request.push_str(&format!("{}: {}\r\n", key, value));
}
}
request.push_str("\r\n");
Ok(request)
}
HttpMethod::POST => {
let json_body = if let Some(b) = body {
serde_json::to_string(&b).map_err(|e| {
HttpError::RequestFailed(format!("JSON serialization failed: {}", e))
})?
} else {
String::new()
};
let mut request = format!(
"POST {} HTTP/1.1\r\n\
Host: {}\r\n\
User-Agent: rumbo-http-client/0.1.0\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
Connection: close\r\n",
full_path,
host,
json_body.len(),
);
if let Some(header_map) = headers {
for (key, value) in header_map {
request.push_str(&format!("{}: {}\r\n", key, value));
}
}
request.push_str("\r\n");
request.push_str(&json_body);
Ok(request)
}
HttpMethod::PUT => {
let json_body = if let Some(b) = body {
serde_json::to_string(&b).map_err(|e| {
HttpError::RequestFailed(format!("JSON serialization failed: {}", e))
})?
} else {
String::new()
};
let mut request = format!(
"PUT {} HTTP/1.1\r\n\
Host: {}\r\n\
User-Agent: rumbo-http-client/0.1.0\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
Connection: close\r\n",
full_path,
host,
json_body.len(),
);
if let Some(header_map) = headers {
for (key, value) in header_map {
request.push_str(&format!("{}: {}\r\n", key, value));
}
}
request.push_str("\r\n");
request.push_str(&json_body);
Ok(request)
}
HttpMethod::PATCH => {
let json_body = if let Some(b) = body {
serde_json::to_string(&b).map_err(|e| {
HttpError::RequestFailed(format!("JSON serialization failed: {}", e))
})?
} else {
String::new()
};
let mut request = format!(
"PATCH {} HTTP/1.1\r\n\
Host: {}\r\n\
User-Agent: rumbo-http-client/0.1.0\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
Connection: close\r\n",
full_path,
host,
json_body.len(),
);
if let Some(header_map) = headers {
for (key, value) in header_map {
request.push_str(&format!("{}: {}\r\n", key, value));
}
}
request.push_str("\r\n");
request.push_str(&json_body);
Ok(request)
}
DELETE => {
let json_body = if let Some(b) = &body {
serde_json::to_string(&b).map_err(|e| {
HttpError::RequestFailed(format!("JSON serialization failed: {}", e))
})?
} else {
String::new()
};
let mut request = format!(
"DELETE {} HTTP/1.1\r\n\
Host: {}\r\n\
User-Agent: rumbo-http-client/0.1.0\r\n",
full_path, host,
);
if body.is_some() {
request.push_str("Content-Type: application/json\r\n");
request.push_str(&format!("Content-Length: {}\r\n", json_body.len()));
}
request.push_str("Connection: close\r\n");
if let Some(header_map) = headers {
for (key, value) in header_map {
request.push_str(&format!("{}: {}\r\n", key, value));
}
}
request.push_str("\r\n");
if body.is_some() {
request.push_str(&json_body);
}
Ok(request)
}
HEAD => {
let mut request = format!(
"HEAD {} HTTP/1.1\r\n\
Host: {}\r\n\
User-Agent: rumbo-http-client/0.1.0\r\n\
Connection: close\r\n",
full_path, host
);
if let Some(header_map) = headers {
for (key, value) in header_map {
request.push_str(&format!("{}: {}\r\n", key, value));
}
}
request.push_str("\r\n");
Ok(request)
}
}
}
async fn read_response<T: AsyncRead + Unpin>(stream: &mut T) -> Result<Vec<u8>, HttpError> {
let mut response = Vec::new();
let mut buf = [0u8; 4096];
loop {
let n = stream
.read(&mut buf)
.await
.map_err(|e| HttpError::RequestFailed(format!("Failed to read response: {}", e)))?;
if n == 0 {
break;
}
response.extend_from_slice(&buf[..n]);
}
Ok(response)
}
}
#[derive(Debug, Clone)]
pub struct Response {
pub status: u16,
pub headers: HashMap<String, String>,
pub body: Option<String>,
}
impl Response {
pub fn parse(response: &str) -> Result<Self> {
let mut parts = response.split("\r\n\r\n");
let headers_section = parts.next().context("Missing headers section")?;
let body = parts
.next()
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let mut lines = headers_section.lines();
let status_line = lines.next().context("Missing status line")?;
let status = Self::parse_status_line(status_line)?;
let mut headers = HashMap::new();
for line in lines {
if let Some((key, value)) = line.split_once(':') {
headers.insert(key.trim().to_lowercase(), value.trim().to_string());
}
}
Ok(Response {
status,
headers,
body,
})
}
fn parse_status_line(status_line: &str) -> Result<u16> {
let parts: Vec<&str> = status_line.split_whitespace().collect();
if parts.len() < 2 {
return Err(anyhow::anyhow!("Invalid status line format"));
}
parts[1]
.parse::<u16>()
.context("Failed to parse status code")
}
pub fn is_success(&self) -> bool {
(200..300).contains(&self.status)
}
pub fn header(&self, name: &str) -> Option<&String> {
self.headers.get(&name.to_lowercase())
}
}
pub use HttpMethod::*;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_response_parsing() {
let response_str = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 13\r\n\r\n{\"hello\":\"world\"}";
let response = Response::parse(response_str).unwrap();
assert_eq!(response.status, 200);
assert_eq!(
response.header("content-type"),
Some(&"application/json".to_string())
);
assert_eq!(response.body, Some("{\"hello\":\"world\"}".to_string()));
assert!(response.is_success());
}
}