use bytes::Bytes;
use http_body_util::Full;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::Request as HyperRequest;
use hyper::Response as HyperResponse;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use tokio::net::TcpListener;
async fn spawn_echo_server() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let _ = http1::Builder::new()
.serve_connection(
hyper_util::rt::TokioIo::new(stream),
service_fn(handle_request),
)
.await;
});
}
});
addr
}
async fn handle_request(
req: HyperRequest<hyper::body::Incoming>,
) -> Result<HyperResponse<Full<Bytes>>, Infallible> {
use http_body_util::BodyExt;
let method = req.method().clone();
let path = req.uri().path().to_string();
let headers = req.headers().clone();
match (method, path.as_str()) {
(hyper::Method::GET, "/hello") => {
Ok(HyperResponse::new(Full::new(Bytes::from("hello world"))))
}
(hyper::Method::GET, "/json") => {
let json = serde_json::json!({"key": "value", "number": 42});
let body = serde_json::to_vec(&json).expect("json serialize");
let mut resp = HyperResponse::new(Full::new(Bytes::from(body)));
resp.headers_mut().insert(
hyper::header::CONTENT_TYPE,
hyper::header::HeaderValue::from_static("application/json"),
);
Ok(resp)
}
(hyper::Method::POST, "/echo") => {
let body_bytes = req
.into_body()
.collect()
.await
.expect("collect body")
.to_bytes();
Ok(HyperResponse::new(Full::new(body_bytes)))
}
(hyper::Method::POST, "/echo-json") => {
let body_bytes = req
.into_body()
.collect()
.await
.expect("collect body")
.to_bytes();
let mut resp = HyperResponse::new(Full::new(body_bytes));
resp.headers_mut().insert(
hyper::header::CONTENT_TYPE,
hyper::header::HeaderValue::from_static("application/json"),
);
Ok(resp)
}
(hyper::Method::PUT, "/put")
| (hyper::Method::DELETE, "/delete")
| (hyper::Method::PATCH, "/patch") => {
let body_bytes = req
.into_body()
.collect()
.await
.expect("collect body")
.to_bytes();
Ok(HyperResponse::new(Full::new(body_bytes)))
}
(hyper::Method::HEAD, "/head") => Ok(HyperResponse::new(Full::new(Bytes::new()))),
(_, "/auth-check") => {
let auth = headers
.get(hyper::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("none");
Ok(HyperResponse::new(Full::new(Bytes::from(auth.to_string()))))
}
(_, "/status/404") => {
let mut resp = HyperResponse::new(Full::new(Bytes::from("not found")));
*resp.status_mut() = hyper::StatusCode::NOT_FOUND;
Ok(resp)
}
(_, "/status/500") => {
let mut resp = HyperResponse::new(Full::new(Bytes::from("server error")));
*resp.status_mut() = hyper::StatusCode::INTERNAL_SERVER_ERROR;
Ok(resp)
}
_ => {
let mut resp = HyperResponse::new(Full::new(Bytes::from("not found")));
*resp.status_mut() = hyper::StatusCode::NOT_FOUND;
Ok(resp)
}
}
}
#[tokio::test]
async fn test_get_request() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/hello");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client
.get(&url)
.expect("GET builder")
.send()
.await
.expect("GET send");
assert_eq!(resp.status(), oxihttp::StatusCode::OK);
let body = resp.body_bytes().await.expect("body read");
assert_eq!(&body[..], b"hello world");
}
#[tokio::test]
async fn test_post_echo() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/echo");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client
.post(&url)
.expect("POST builder")
.body(Bytes::from("ping payload"))
.send()
.await
.expect("POST send");
assert_eq!(resp.status(), oxihttp::StatusCode::OK);
let body = resp.body_bytes().await.expect("body read");
assert_eq!(&body[..], b"ping payload");
}
#[tokio::test]
async fn test_invalid_uri_error() {
let client = oxihttp::Client::builder().build().expect("client build");
let result = client.get("not a valid uri!!!{}");
assert!(result.is_err(), "invalid URI should return Err");
}
#[tokio::test]
async fn test_put_request() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/put");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client
.put(&url)
.expect("PUT builder")
.body(Bytes::from("put data"))
.send()
.await
.expect("PUT send");
assert_eq!(resp.status(), oxihttp::StatusCode::OK);
let body = resp.body_bytes().await.expect("body read");
assert_eq!(&body[..], b"put data");
}
#[tokio::test]
async fn test_delete_request() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/delete");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client
.delete(&url)
.expect("DELETE builder")
.body(Bytes::from("delete data"))
.send()
.await
.expect("DELETE send");
assert_eq!(resp.status(), oxihttp::StatusCode::OK);
}
#[tokio::test]
async fn test_patch_request() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/patch");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client
.patch(&url)
.expect("PATCH builder")
.body(Bytes::from("patch data"))
.send()
.await
.expect("PATCH send");
assert_eq!(resp.status(), oxihttp::StatusCode::OK);
let body = resp.body_bytes().await.expect("body read");
assert_eq!(&body[..], b"patch data");
}
#[tokio::test]
async fn test_head_request() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/head");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client
.head(&url)
.expect("HEAD builder")
.send()
.await
.expect("HEAD send");
assert_eq!(resp.status(), oxihttp::StatusCode::OK);
}
#[tokio::test]
async fn test_json_body_and_response() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/echo-json");
let client = oxihttp::Client::builder().build().expect("client build");
let payload = serde_json::json!({"name": "test", "value": 123});
let resp = client
.post(&url)
.expect("POST builder")
.json(&payload)
.expect("json body")
.send()
.await
.expect("POST send");
assert_eq!(resp.status(), oxihttp::StatusCode::OK);
let result: serde_json::Value = resp.body_json().await.expect("json parse");
assert_eq!(result["name"], "test");
assert_eq!(result["value"], 123);
}
#[tokio::test]
async fn test_body_text() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/hello");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client.get(&url).expect("GET").send().await.expect("send");
let text = resp.body_text().await.expect("body text");
assert_eq!(text, "hello world");
}
#[tokio::test]
async fn test_get_json_convenience() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/json");
let client = oxihttp::Client::builder().build().expect("client build");
let result: serde_json::Value = client.get_json(&url).await.expect("get_json");
assert_eq!(result["key"], "value");
assert_eq!(result["number"], 42);
}
#[tokio::test]
async fn test_bearer_token() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/auth-check");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client
.get(&url)
.expect("GET builder")
.bearer_token("my-secret-token")
.expect("bearer")
.send()
.await
.expect("send");
let body = resp.body_text().await.expect("body");
assert_eq!(body, "Bearer my-secret-token");
}
#[tokio::test]
async fn test_basic_auth() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/auth-check");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client
.get(&url)
.expect("GET builder")
.basic_auth("user", Some("pass"))
.expect("basic auth")
.send()
.await
.expect("send");
let body = resp.body_text().await.expect("body");
assert_eq!(body, "Basic dXNlcjpwYXNz");
}
#[tokio::test]
async fn test_custom_headers() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/auth-check");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client
.get(&url)
.expect("GET builder")
.header("authorization", "Custom xyz123")
.expect("header")
.send()
.await
.expect("send");
let body = resp.body_text().await.expect("body");
assert_eq!(body, "Custom xyz123");
}
#[tokio::test]
async fn test_error_for_status_404() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/status/404");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client.get(&url).expect("GET").send().await.expect("send");
assert_eq!(resp.status(), oxihttp::StatusCode::NOT_FOUND);
let err = resp.error_for_status();
assert!(err.is_err(), "404 should produce an error");
}
#[tokio::test]
async fn test_error_for_status_500() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/status/500");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client.get(&url).expect("GET").send().await.expect("send");
let err = resp.error_for_status();
assert!(err.is_err(), "500 should produce an error");
}
#[tokio::test]
async fn test_error_for_status_success() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/hello");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client.get(&url).expect("GET").send().await.expect("send");
let resp = resp
.error_for_status()
.expect("200 should not produce error");
assert_eq!(resp.status(), oxihttp::StatusCode::OK);
}
#[tokio::test]
async fn test_response_debug() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/hello");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client.get(&url).expect("GET").send().await.expect("send");
let debug = format!("{resp:?}");
assert!(debug.contains("Response"));
assert!(debug.contains("200"));
}
#[tokio::test]
async fn test_content_length() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/hello");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client.get(&url).expect("GET").send().await.expect("send");
assert_eq!(resp.content_length(), Some(11));
}
#[tokio::test]
async fn test_content_type() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/json");
let client = oxihttp::Client::builder().build().expect("client build");
let resp = client.get(&url).expect("GET").send().await.expect("send");
assert_eq!(resp.content_type(), Some("application/json"));
}
#[tokio::test]
async fn test_client_builder_user_agent() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/hello");
let client = oxihttp::Client::builder()
.user_agent("OxiHTTP/0.1.0")
.build()
.expect("client build");
let resp = client.get(&url).expect("GET").send().await.expect("send");
assert_eq!(resp.status(), oxihttp::StatusCode::OK);
}
#[tokio::test]
async fn test_per_request_timeout() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let _ = http1::Builder::new()
.serve_connection(
hyper_util::rt::TokioIo::new(stream),
service_fn(|_req: HyperRequest<hyper::body::Incoming>| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
Ok::<_, Infallible>(HyperResponse::new(Full::new(Bytes::new())))
}),
)
.await;
});
}
});
let url = format!("http://{addr}/slow");
let client = oxihttp::Client::builder().build().expect("client build");
let result = client
.get(&url)
.expect("GET")
.timeout(std::time::Duration::from_millis(100))
.send()
.await;
assert!(result.is_err(), "should timeout");
let err = result.expect_err("timeout error");
assert!(err.is_timeout(), "error should be timeout");
}
#[tokio::test]
async fn test_form_body() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/echo");
let client = oxihttp::Client::builder().build().expect("client build");
let form = oxihttp::FormBody::new()
.field("username", "alice")
.field("password", "secret");
let resp = client
.post(&url)
.expect("POST builder")
.form(&form)
.send()
.await
.expect("POST send");
let body = resp.body_text().await.expect("body text");
assert!(body.contains("username=alice"));
assert!(body.contains("password=secret"));
}
#[tokio::test]
async fn test_redirect_disabled() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let _ = http1::Builder::new()
.serve_connection(
hyper_util::rt::TokioIo::new(stream),
service_fn(|_req: HyperRequest<hyper::body::Incoming>| async {
Ok::<_, Infallible>(
HyperResponse::builder()
.status(302)
.header("location", "/redirected")
.body(Full::new(Bytes::new()))
.expect("response build"),
)
}),
)
.await;
});
}
});
let url = format!("http://{addr}/start");
let client = oxihttp::Client::builder()
.redirect_policy(oxihttp::RedirectPolicy::None)
.build()
.expect("client build");
let resp = client.get(&url).expect("GET").send().await.expect("send");
assert_eq!(resp.status().as_u16(), 302);
}
#[tokio::test]
async fn test_redirect_follow() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let _ = http1::Builder::new()
.serve_connection(
hyper_util::rt::TokioIo::new(stream),
service_fn(|req: HyperRequest<hyper::body::Incoming>| async move {
match req.uri().path() {
"/start" => Ok::<_, Infallible>(
HyperResponse::builder()
.status(302)
.header("location", "/destination")
.body(Full::new(Bytes::new()))
.expect("resp"),
),
"/destination" => {
Ok(HyperResponse::new(Full::new(Bytes::from("arrived"))))
}
_ => Ok(HyperResponse::new(Full::new(Bytes::from("404")))),
}
}),
)
.await;
});
}
});
let url = format!("http://{addr}/start");
let client = oxihttp::Client::builder()
.redirect_policy(oxihttp::RedirectPolicy::Limited(5))
.build()
.expect("client build");
let resp = client.get(&url).expect("GET").send().await.expect("send");
assert_eq!(resp.status(), oxihttp::StatusCode::OK);
let body = resp.body_text().await.expect("body");
assert_eq!(body, "arrived");
}
#[tokio::test]
async fn test_redirect_limit_exceeded() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = Arc::clone(&counter);
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
let counter = Arc::clone(&counter_clone);
tokio::spawn(async move {
let _ = http1::Builder::new()
.serve_connection(
hyper_util::rt::TokioIo::new(stream),
service_fn(move |_req: HyperRequest<hyper::body::Incoming>| {
let n = counter.fetch_add(1, Ordering::SeqCst);
async move {
Ok::<_, Infallible>(
HyperResponse::builder()
.status(302)
.header("location", format!("/redirect/{n}"))
.body(Full::new(Bytes::new()))
.expect("resp"),
)
}
}),
)
.await;
});
}
});
let url = format!("http://{addr}/start");
let client = oxihttp::Client::builder()
.redirect_policy(oxihttp::RedirectPolicy::Limited(3))
.build()
.expect("client build");
let result = client.get(&url).expect("GET").send().await;
assert!(result.is_err(), "should fail due to redirect limit");
let err = result.expect_err("redirect error");
assert!(err.is_redirect(), "should be redirect error");
}
#[tokio::test]
async fn test_facade_version() {
let v = oxihttp::version();
assert!(!v.is_empty());
assert!(v.contains('.'));
}
#[tokio::test]
async fn test_facade_get_convenience() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/hello");
let resp = oxihttp::get(&url).await.expect("facade get");
assert_eq!(resp.status(), oxihttp::StatusCode::OK);
let body = resp.body_text().await.expect("body");
assert_eq!(body, "hello world");
}
#[tokio::test]
async fn test_facade_post_convenience() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/echo");
let resp = oxihttp::post(&url, Bytes::from("data"))
.await
.expect("facade post");
assert_eq!(resp.status(), oxihttp::StatusCode::OK);
let body = resp.body_text().await.expect("body");
assert_eq!(body, "data");
}
#[tokio::test]
async fn test_pool_sequential_reuse() {
let addr = spawn_echo_server().await;
let url = format!("http://{addr}/hello");
let client = oxihttp::Client::builder()
.pool_max_idle_per_host(2)
.build()
.expect("client build");
for i in 0..10 {
let resp = client
.get(&url)
.expect("GET builder")
.send()
.await
.unwrap_or_else(|e| panic!("request {i} failed: {e}"));
assert_eq!(
resp.status(),
oxihttp::StatusCode::OK,
"request {i} returned non-200"
);
let body = resp.body_text().await.expect("body text");
assert_eq!(body, "hello world", "request {i} body mismatch");
}
}
#[tokio::test]
async fn test_concurrent_requests() {
let addr = spawn_echo_server().await;
let url = Arc::new(format!("http://{addr}/hello"));
let client = Arc::new(
oxihttp::Client::builder()
.pool_max_idle_per_host(10)
.build()
.expect("client build"),
);
let tasks: Vec<_> = (0..10)
.map(|i| {
let client = Arc::clone(&client);
let url = Arc::clone(&url);
tokio::spawn(async move {
let resp = client
.get(url.as_str())
.expect("GET builder")
.send()
.await
.unwrap_or_else(|e| panic!("concurrent request {i} failed: {e}"));
assert_eq!(
resp.status(),
oxihttp::StatusCode::OK,
"concurrent request {i} returned non-200"
);
let body = resp.body_text().await.expect("body text");
assert_eq!(body, "hello world", "concurrent request {i} body mismatch");
})
})
.collect();
for (i, task) in tasks.into_iter().enumerate() {
task.await
.unwrap_or_else(|e| panic!("task {i} panicked: {e}"));
}
}
async fn spawn_redirect_method_server(redirect_status: u16) -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
let status = redirect_status;
tokio::spawn(async move {
let _ = http1::Builder::new()
.serve_connection(
hyper_util::rt::TokioIo::new(stream),
service_fn(move |req: HyperRequest<hyper::body::Incoming>| async move {
let path = req.uri().path().to_string();
let method = req.method().clone();
match (method.as_str(), path.as_str()) {
(_, "/original") => Ok::<_, Infallible>(
HyperResponse::builder()
.status(status)
.header("location", "/destination")
.body(Full::new(Bytes::new()))
.expect("resp build"),
),
(_, "/destination") => {
Ok(HyperResponse::new(Full::new(Bytes::from("final"))))
}
_ => {
let mut r =
HyperResponse::new(Full::new(Bytes::from("not found")));
*r.status_mut() = hyper::StatusCode::NOT_FOUND;
Ok(r)
}
}
}),
)
.await;
});
}
});
addr
}
#[tokio::test]
async fn test_redirect_301_rewrites_method_to_get() {
let addr = spawn_redirect_method_server(301).await;
let url = format!("http://{addr}/original");
let client = oxihttp::Client::builder()
.redirect_policy(oxihttp::RedirectPolicy::Limited(5))
.build()
.expect("client build");
let resp = client
.post(&url)
.expect("POST builder")
.body(bytes::Bytes::from("should be dropped"))
.send()
.await
.expect("send");
assert_eq!(
resp.status(),
oxihttp::StatusCode::OK,
"expected 200 after 301 redirect"
);
let body = resp.body_text().await.expect("body text");
assert_eq!(
body, "final",
"expected response body 'final' from /destination"
);
}
#[tokio::test]
async fn test_redirect_307_preserves_method() {
let addr = spawn_redirect_method_server(307).await;
let url = format!("http://{addr}/original");
let client = oxihttp::Client::builder()
.redirect_policy(oxihttp::RedirectPolicy::Limited(5))
.build()
.expect("client build");
let resp = client
.post(&url)
.expect("POST builder")
.body(bytes::Bytes::from("preserved body"))
.send()
.await
.expect("send");
assert_eq!(
resp.status(),
oxihttp::StatusCode::OK,
"expected 200 after 307 redirect"
);
let body = resp.body_text().await.expect("body text");
assert_eq!(
body, "final",
"expected response body 'final' from /destination"
);
}
#[tokio::test]
async fn test_redirect_308_preserves_method() {
let addr = spawn_redirect_method_server(308).await;
let url = format!("http://{addr}/original");
let client = oxihttp::Client::builder()
.redirect_policy(oxihttp::RedirectPolicy::Limited(5))
.build()
.expect("client build");
let resp = client
.post(&url)
.expect("POST builder")
.body(bytes::Bytes::from("preserved body"))
.send()
.await
.expect("send");
assert_eq!(
resp.status(),
oxihttp::StatusCode::OK,
"expected 200 after 308 redirect"
);
let body = resp.body_text().await.expect("body text");
assert_eq!(
body, "final",
"expected response body 'final' from /destination"
);
}
#[tokio::test]
async fn test_connect_timeout_fires_on_unreachable_host() {
let client = oxihttp::Client::builder()
.connect_timeout(std::time::Duration::from_millis(100))
.build()
.expect("client build");
let result = tokio::time::timeout(
std::time::Duration::from_secs(3),
client
.get("http://192.0.2.1:1/")
.expect("GET builder")
.send(),
)
.await;
match result {
Ok(inner) => {
assert!(
inner.is_err(),
"expected a connection error for unreachable 192.0.2.1:1"
);
}
Err(_elapsed) => {
}
}
}