From f1e9cff1dea765c2a6d897ebc3e43306292ea27e Mon Sep 17 00:00:00 2001 From: Pieter Agten Date: Thu, 18 Jun 2026 10:28:08 +0200 Subject: [PATCH 1/5] Add support for wrapping arbitrary bodies as simple hyper client request bodies --- simple-hyper-client/src/async_client.rs | 30 ++- simple-hyper-client/src/blocking/client.rs | 4 +- simple-hyper-client/src/blocking/mod.rs | 4 +- simple-hyper-client/src/body.rs | 207 +++++++++++++++++++++ simple-hyper-client/src/lib.rs | 6 +- simple-hyper-client/src/shared_body.rs | 136 -------------- 6 files changed, 225 insertions(+), 162 deletions(-) create mode 100644 simple-hyper-client/src/body.rs delete mode 100644 simple-hyper-client/src/shared_body.rs diff --git a/simple-hyper-client/src/async_client.rs b/simple-hyper-client/src/async_client.rs index 29624e5..708d3bb 100644 --- a/simple-hyper-client/src/async_client.rs +++ b/simple-hyper-client/src/async_client.rs @@ -4,12 +4,12 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +use crate::body::RequestBody; use crate::connector::{ConnectorAdapter, NetworkConnector}; use crate::error::Error; -use crate::shared_body::SharedBody; use crate::{HyperClient, HyperClientBuilder, Response}; -use headers::{ContentLength, Header, HeaderMap, HeaderMapExt}; +use headers::{Header, HeaderMap, HeaderMapExt}; use hyper::{Method, Request, Uri}; use std::convert::{TryFrom, TryInto}; @@ -30,7 +30,7 @@ use std::time::Duration; /// [hyper's `Client` type]: https://docs.rs/hyper-util/latest/hyper_util/client/legacy/struct.Client.html #[derive(Clone)] pub struct Client { - inner: Arc>, + inner: Arc>, } macro_rules! define_method_fn { @@ -66,7 +66,7 @@ impl Client { /// This method can be used instead of [Client::request] /// if the caller already has a [Request]. - pub async fn send(&self, request: Request) -> Result { + pub async fn send(&self, request: Request) -> Result { Ok(self.inner.request(request).await?) } @@ -158,7 +158,7 @@ pub(crate) struct RequestDetails { pub(crate) method: Method, pub(crate) uri: Uri, pub(crate) headers: HeaderMap, - pub(crate) body: Option, + pub(crate) body: Option, } impl fmt::Debug for RequestDetails { @@ -187,24 +187,16 @@ impl RequestDetails { Ok(client.inner.request(req).await?) } - pub fn into_request(mut self) -> Result, Error> { + pub fn into_request(self) -> Result, Error> { let can_have_body = match self.method { // See RFC 7231 section 4.3 Method::GET | Method::HEAD | Method::DELETE => false, _ => true, }; let body = match can_have_body { - true => { - let body = self.body.unwrap_or_else(|| SharedBody::empty()); - // NOTE: body cannot be chunked in this implementation, so we - // don't worry about chunked encoding here. But if this changes - // then we should not set `ContentLength` automatically if the - // request body is chunked, see RFC 7230 section 3.3.2. - self.headers.typed_insert(ContentLength(body.len() as u64)); - body - } + true => self.body.unwrap_or_else(|| RequestBody::empty()), false if self.body.is_some() => return Err(Error::BodyNotAllowed(self.method)), - false => SharedBody::empty(), + false => RequestBody::empty(), }; let mut req = Request::builder().method(self.method).uri(self.uri); match req.headers_mut() { @@ -212,7 +204,7 @@ impl RequestDetails { *headers = self.headers; } // There is an error in req, but the only way to extract the error is through `req.body()` - None => match req.body(SharedBody::empty()) { + None => match req.body(RequestBody::empty()) { Err(e) => return Err(e.into()), Ok(_) => { panic!("request builder must have errors if `fn headers_mut()` returns None") @@ -241,7 +233,7 @@ pub struct RequestBuilder<'a> { impl<'a> RequestBuilder<'a> { /// Set the request body. - pub fn body>(mut self, body: B) -> Self { + pub fn body>(mut self, body: B) -> Self { self.details.body = Some(body.into()); self } @@ -264,7 +256,7 @@ impl<'a> RequestBuilder<'a> { /// /// Prefer [RequestBuilder::send] unless you have a specific /// need to get the resultant [Request]. - pub fn build(self) -> Result, Error> { + pub fn build(self) -> Result, Error> { self.details.into_request() } diff --git a/simple-hyper-client/src/blocking/client.rs b/simple-hyper-client/src/blocking/client.rs index 01c1faa..882df1d 100644 --- a/simple-hyper-client/src/blocking/client.rs +++ b/simple-hyper-client/src/blocking/client.rs @@ -7,9 +7,9 @@ use super::body::Body; use super::Response; use crate::async_client::{ClientBuilder as AsyncClientBuilder, RequestDetails}; +use crate::body::RequestBody; use crate::connector::NetworkConnector; use crate::error::Error; -use crate::shared_body::SharedBody; use futures_executor::block_on; use headers::{Header, HeaderMap, HeaderMapExt}; @@ -206,7 +206,7 @@ pub struct RequestBuilder<'a> { impl<'a> RequestBuilder<'a> { /// Set the request body. - pub fn body>(mut self, body: B) -> Self { + pub fn body>(mut self, body: B) -> Self { self.details.body = Some(body.into()); self } diff --git a/simple-hyper-client/src/blocking/mod.rs b/simple-hyper-client/src/blocking/mod.rs index d956619..7527b04 100644 --- a/simple-hyper-client/src/blocking/mod.rs +++ b/simple-hyper-client/src/blocking/mod.rs @@ -10,7 +10,7 @@ //! async tasks. Additionally, since the client holds a connection pool //! internally, it is advised that instances be reused as much as possible. -use crate::shared_body::SharedBody; +use crate::body::RequestBody; mod body; mod client; @@ -18,5 +18,5 @@ mod client; pub use self::body::Body; pub use self::client::{Client, ClientBuilder, RequestBuilder}; -pub type Request = hyper::Request; +pub type Request = hyper::Request; pub type Response = hyper::Response; diff --git a/simple-hyper-client/src/body.rs b/simple-hyper-client/src/body.rs new file mode 100644 index 0000000..3e11f1d --- /dev/null +++ b/simple-hyper-client/src/body.rs @@ -0,0 +1,207 @@ +/* Copyright (c) Fortanix, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use http_body_util::combinators::BoxBody; +use http_body_util::BodyExt; +use hyper::body::{Body, Buf, Frame, SizeHint}; + +use std::cmp; +use std::error::Error; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +/// This is an implementation of `hyper::body::Body` for use with HTTP +/// `Request`s. +/// +/// This can be constructed from `Arc>`, allowing the data to be +/// shared. The type can also wrap arbitrary other `hyper::body::Body` +/// instances. +pub struct RequestBody(InnerBody); + +enum InnerBody { + Shared(Option), + Wrapped(BoxBody, Box>), +} + +impl RequestBody { + /// Create an empty request body. + pub fn empty() -> Self { + Self(InnerBody::Shared(None)) + } + + /// Create a `RequestBody` from an arbitrary other `hyper::body::Body`. + pub fn wrap(body: B) -> Self + where + B: Body + Send + Sync + 'static, + D: Buf + Send + Sync + 'static, + E: Error + Send + Sync + 'static, + { + Self(InnerBody::Wrapped( + body.map_frame(|f| f.map_data(|d| Box::new(d) as Box)) + .map_err(|e| Box::new(e) as Box) + .boxed(), + )) + } +} + +impl Default for RequestBody { + /// Returns `Self::empty()`. + #[inline] + fn default() -> Self { + Self::empty() + } +} + +impl From>> for RequestBody { + fn from(arc: Arc>) -> Self { + Self(InnerBody::Shared(Some(SharedBytes::Arc(arc)))) + } +} + +impl From> for RequestBody { + fn from(vec: Vec) -> Self { + Self(InnerBody::Shared(Some(SharedBytes::Arc(Arc::new(vec))))) + } +} + +impl From for RequestBody { + fn from(s: String) -> Self { + Self(InnerBody::Shared(Some(SharedBytes::Arc(Arc::new( + s.into_bytes(), + ))))) + } +} + +impl From<&'static [u8]> for RequestBody { + fn from(slice: &'static [u8]) -> Self { + Self(InnerBody::Shared(Some(SharedBytes::Static(slice)))) + } +} + +impl From<&'static str> for RequestBody { + fn from(s: &'static str) -> Self { + Self(InnerBody::Shared(Some(SharedBytes::Static(s.as_bytes())))) + } +} + +impl Body for RequestBody { + type Data = Buffer; + type Error = Box; + + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + match &mut self.get_mut().0 { + InnerBody::Shared(maybe_bytes) => { + let opt = maybe_bytes + .take() + .map(|bytes| Buffer::Shared(SharedBuf { bytes, pos: 0 })) + .map(Frame::data) + .map(Ok); + Poll::Ready(opt) + } + + InnerBody::Wrapped(box_body) => { + BoxBody::poll_frame(Pin::new(box_body), cx).map(|opt| { + opt.map(|res| res.map(|frame| frame.map_data(|bytes| Buffer::Wrapped(bytes)))) + }) + } + } + } + + fn is_end_stream(&self) -> bool { + match &self.0 { + InnerBody::Shared(maybe_bytes) => maybe_bytes.is_none(), + InnerBody::Wrapped(box_body) => box_body.is_end_stream(), + } + } + + fn size_hint(&self) -> SizeHint { + match &self.0 { + InnerBody::Shared(maybe_bytes) => { + let len = maybe_bytes + .as_ref() + .map(SharedBytes::len) + .unwrap_or_default(); + SizeHint::with_exact(len as u64) + } + InnerBody::Wrapped(box_body) => box_body.size_hint(), + } + } +} + +/// The `hyper::body::Body::Data` type for a [`RequestBody`]. +pub enum Buffer { + Shared(SharedBuf), + Wrapped(Box), +} + +impl Buf for Buffer { + fn remaining(&self) -> usize { + match self { + Self::Shared(shared_buf) => shared_buf.remaining(), + Self::Wrapped(bytes) => bytes.remaining(), + } + } + + fn chunk(&self) -> &[u8] { + match self { + Self::Shared(shared_buf) => shared_buf.chunk(), + Self::Wrapped(bytes) => bytes.chunk(), + } + } + + fn advance(&mut self, cnt: usize) { + match self { + Self::Shared(shared_buf) => shared_buf.advance(cnt), + Self::Wrapped(bytes) => bytes.advance(cnt), + } + } +} + +pub struct SharedBuf { + bytes: SharedBytes, + pos: usize, +} + +impl SharedBuf { + fn len(&self) -> usize { + self.bytes.len() + } +} + +impl Buf for SharedBuf { + fn remaining(&self) -> usize { + self.len() - self.pos + } + + fn chunk(&self) -> &[u8] { + match self.bytes { + SharedBytes::Arc(ref bytes) => &bytes[self.pos..], + SharedBytes::Static(ref bytes) => &bytes[self.pos..], + } + } + + fn advance(&mut self, cnt: usize) { + self.pos = cmp::min(self.len(), self.pos + cnt); + } +} + +enum SharedBytes { + Arc(Arc>), + Static(&'static [u8]), +} + +impl SharedBytes { + fn len(&self) -> usize { + match self { + Self::Arc(ref bytes) => bytes.len(), + Self::Static(ref bytes) => bytes.len(), + } + } +} diff --git a/simple-hyper-client/src/lib.rs b/simple-hyper-client/src/lib.rs index 1d06782..3e0cb12 100644 --- a/simple-hyper-client/src/lib.rs +++ b/simple-hyper-client/src/lib.rs @@ -6,18 +6,18 @@ mod async_client; pub mod blocking; +mod body; mod connector; mod error; -mod shared_body; mod util; pub use self::async_client::*; +pub use self::body::RequestBody; pub use self::connector::{ ConnectError, HttpConnection, HttpConnector, HyperConnectorAdapter, NetworkConnection, NetworkConnector, }; pub use self::error::{Error, HyperClientError}; -pub use self::shared_body::SharedBody; pub use self::util::{aggregate, to_bytes}; pub use hyper::body::{Body, Buf, Bytes, Incoming}; @@ -25,7 +25,7 @@ pub use hyper::{self, Method, StatusCode, Uri, Version}; pub use hyper_util::client::legacy::{Builder as HyperClientBuilder, Client as HyperClient}; pub use tower_service; -pub type Request = hyper::Request; +pub type Request = hyper::Request; pub type Response = hyper::Response; pub mod compat { diff --git a/simple-hyper-client/src/shared_body.rs b/simple-hyper-client/src/shared_body.rs deleted file mode 100644 index 1e60769..0000000 --- a/simple-hyper-client/src/shared_body.rs +++ /dev/null @@ -1,136 +0,0 @@ -/* Copyright (c) Fortanix, Inc. - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -use hyper::body::{Body, Buf, Frame}; - -use std::cmp; -use std::pin::Pin; -use std::sync::Arc; -use std::task::{Context, Poll}; - -/// This is an alternative to `hyper::Body` for use with HTTP `Request`s -/// -/// This can be constructed from `Arc>` while `hyper::Body` cannot. -/// Additionally this type provides a method to get its length. -pub struct SharedBody(Option); - -enum InnerBuf { - Arc(Arc>), - Static(&'static [u8]), -} - -impl AsRef<[u8]> for SharedBody { - fn as_ref(&self) -> &[u8] { - match self.0.as_ref() { - Some(InnerBuf::Arc(vec)) => vec, - Some(InnerBuf::Static(slice)) => slice, - None => &[], - } - } -} - -impl SharedBody { - pub fn len(&self) -> usize { - match self.0.as_ref() { - Some(InnerBuf::Arc(vec)) => vec.len(), - Some(InnerBuf::Static(slice)) => slice.len(), - None => 0, - } - } - - pub fn empty() -> Self { - SharedBody(None) - } -} - -impl Default for SharedBody { - /// Returns `SharedBody::empty()`. - #[inline] - fn default() -> Self { - SharedBody::empty() - } -} - -impl From>> for SharedBody { - fn from(arc: Arc>) -> Self { - SharedBody(Some(InnerBuf::Arc(arc))) - } -} - -impl From> for SharedBody { - fn from(vec: Vec) -> Self { - SharedBody(Some(InnerBuf::Arc(Arc::new(vec)))) - } -} - -impl From for SharedBody { - fn from(s: String) -> Self { - SharedBody(Some(InnerBuf::Arc(Arc::new(s.into_bytes())))) - } -} - -impl From<&'static [u8]> for SharedBody { - fn from(slice: &'static [u8]) -> Self { - SharedBody(Some(InnerBuf::Static(slice))) - } -} - -impl From<&'static str> for SharedBody { - fn from(s: &'static str) -> Self { - SharedBody(Some(InnerBuf::Static(s.as_bytes()))) - } -} - -impl Body for SharedBody { - type Data = SharedBuf; - type Error = crate::Error; - - fn poll_frame( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll, Self::Error>>> { - let opt = self - .get_mut() - .0 - .take() - .map(|bytes| SharedBuf { bytes, pos: 0 }) - .map(Frame::data) - .map(Ok); - - Poll::Ready(opt) - } -} - -pub struct SharedBuf { - bytes: InnerBuf, - pos: usize, -} - -impl SharedBuf { - fn len(&self) -> usize { - match self.bytes { - InnerBuf::Arc(ref bytes) => bytes.len(), - InnerBuf::Static(ref bytes) => bytes.len(), - } - } -} - -impl Buf for SharedBuf { - fn remaining(&self) -> usize { - self.len() - self.pos - } - - fn chunk(&self) -> &[u8] { - match self.bytes { - InnerBuf::Arc(ref bytes) => &bytes[self.pos..], - InnerBuf::Static(ref bytes) => &bytes[self.pos..], - } - } - - fn advance(&mut self, cnt: usize) { - self.pos = cmp::min(self.len(), self.pos + cnt); - } -} From 62aecd6e216e5ad3ab1d7fcda592fca7d9b1509f Mon Sep 17 00:00:00 2001 From: Pieter Agten Date: Sun, 21 Jun 2026 16:52:34 +0200 Subject: [PATCH 2/5] Test that ContentLength header is set with requests --- Cargo.lock | 1 + simple-hyper-client/Cargo.toml | 1 + simple-hyper-client/src/async_client.rs | 37 ++++++++++++++++++++----- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec79532..b129e1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,6 +1011,7 @@ dependencies = [ "headers", "http", "http-body-util", + "httparse", "hyper", "hyper-util", "thiserror", diff --git a/simple-hyper-client/Cargo.toml b/simple-hyper-client/Cargo.toml index 247d2f8..72ede5d 100644 --- a/simple-hyper-client/Cargo.toml +++ b/simple-hyper-client/Cargo.toml @@ -27,4 +27,5 @@ tower-service = "0.3" [dev-dependencies] http-body-util = { version = "0.1", features = ["channel"] } +httparse = { version = "1" } futures-util = "0.3" diff --git a/simple-hyper-client/src/async_client.rs b/simple-hyper-client/src/async_client.rs index 708d3bb..627b44f 100644 --- a/simple-hyper-client/src/async_client.rs +++ b/simple-hyper-client/src/async_client.rs @@ -290,31 +290,34 @@ mod tests { use super::*; use crate::connector::HttpConnector; use crate::util::to_bytes; - use headers::ContentType; + use headers::{ContentLength, ContentType}; use hyper::StatusCode; use std::net::SocketAddr; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; + use tokio::sync::oneshot; const RESPONSE_OK: &str = "HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, world!\r\n"; const RESPONSE_404: &str = "HTTP/1.1 404 Not Found\r\nContent-Length: 23\r\n\r\nResource was not found.\r\n"; - async fn test_http_server(resp: &'static str) -> SocketAddr { + async fn test_http_server(resp: &'static str, body_tx: oneshot::Sender>) -> SocketAddr { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut input = Vec::new(); - stream.read(&mut input).await.unwrap(); stream.write_all(resp.as_bytes()).await.unwrap(); + stream.read_to_end(&mut input).await.unwrap(); + let _ = body_tx.send(input); }); addr } #[tokio::test] async fn http_client() { - let addr = test_http_server(RESPONSE_OK).await; + let (tx, rx) = oneshot::channel(); + let addr = test_http_server(RESPONSE_OK, tx).await; let url = format!("http://{}/", addr); let connector = HttpConnector::new(); @@ -328,14 +331,34 @@ mod tests { .await .unwrap(); + // Parse the request received by the server + let mut headers = [httparse::EMPTY_HEADER; 64]; + let mut request = httparse::Request::new(&mut headers); + let req_buf = rx.await.unwrap(); + let body_idx = request.parse(&req_buf).unwrap().unwrap(); + assert_eq!(request.method, Some("POST")); + assert_eq!(request.path, Some("/")); + assert_eq!(request.version, Some(1)); + let content_length = request + .headers + .iter() + .find(|header| header.name == ContentLength::name()) + .unwrap(); + assert_eq!(content_length.value, "15".as_bytes()); + assert_eq!( + str::from_utf8(&req_buf[body_idx..]).unwrap(), + "{\"key\":\"value\"}" + ); + assert_eq!(response.status(), StatusCode::OK); - let body = to_bytes(response).await.unwrap(); - assert_eq!(body, "Hello, world!".as_bytes()); + let response_body = to_bytes(response).await.unwrap(); + assert_eq!(response_body, "Hello, world!".as_bytes()); } #[tokio::test] async fn drop_client_before_response() { - let addr = test_http_server(RESPONSE_404).await; + let (tx, _rx) = oneshot::channel(); + let addr = test_http_server(RESPONSE_404, tx).await; let url = format!("http://{}/", addr); let connector = HttpConnector::new(); From f0c10e69860043eb1beebe4afd82355ee748ed1a Mon Sep 17 00:00:00 2001 From: Pieter Agten Date: Sun, 21 Jun 2026 17:27:13 +0200 Subject: [PATCH 3/5] Don't error out with BodyNotAllowed on empty request bodies --- simple-hyper-client/src/async_client.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/simple-hyper-client/src/async_client.rs b/simple-hyper-client/src/async_client.rs index 627b44f..50ace8b 100644 --- a/simple-hyper-client/src/async_client.rs +++ b/simple-hyper-client/src/async_client.rs @@ -10,6 +10,7 @@ use crate::error::Error; use crate::{HyperClient, HyperClientBuilder, Response}; use headers::{Header, HeaderMap, HeaderMapExt}; +use hyper::body::Body; use hyper::{Method, Request, Uri}; use std::convert::{TryFrom, TryInto}; @@ -193,10 +194,12 @@ impl RequestDetails { Method::GET | Method::HEAD | Method::DELETE => false, _ => true, }; - let body = match can_have_body { - true => self.body.unwrap_or_else(|| RequestBody::empty()), - false if self.body.is_some() => return Err(Error::BodyNotAllowed(self.method)), - false => RequestBody::empty(), + let body = if can_have_body { + self.body.unwrap_or_else(|| RequestBody::empty()) + } else if self.body.is_some_and(|body| body.size_hint().lower() > 0) { + return Err(Error::BodyNotAllowed(self.method)); + } else { + RequestBody::empty() }; let mut req = Request::builder().method(self.method).uri(self.uri); match req.headers_mut() { From 410101eb86c77905427aeb508aa52b89867f0f60 Mon Sep 17 00:00:00 2001 From: Pieter Agten Date: Mon, 22 Jun 2026 10:16:23 +0200 Subject: [PATCH 4/5] Add convenience methods to Error type --- Cargo.lock | 53 ++++++++++++++++++++++++++++++++ simple-hyper-client/Cargo.toml | 5 +-- simple-hyper-client/src/error.rs | 5 ++- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b129e1b..92a8117 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,6 +197,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -232,6 +241,28 @@ dependencies = [ "typenum", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -884,6 +915,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -965,6 +1005,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.219" @@ -1006,6 +1052,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" name = "simple-hyper-client" version = "0.3.0" dependencies = [ + "derive_more", "futures-executor", "futures-util", "headers", @@ -1217,6 +1264,12 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/simple-hyper-client/Cargo.toml b/simple-hyper-client/Cargo.toml index 72ede5d..2889fd3 100644 --- a/simple-hyper-client/Cargo.toml +++ b/simple-hyper-client/Cargo.toml @@ -14,6 +14,7 @@ categories = ["web-programming::http-client"] edition = "2018" [dependencies] +derive_more = { version = "2", features = ["is_variant", "try_unwrap", "unwrap"] } futures-executor = "0.3" futures-util = "0.3" headers = "0.4" @@ -26,6 +27,6 @@ tokio = { version = "1", features = ["rt", "macros", "net", "sync", "time"] } tower-service = "0.3" [dev-dependencies] -http-body-util = { version = "0.1", features = ["channel"] } -httparse = { version = "1" } futures-util = "0.3" +http-body-util = { version = "0.1", features = ["channel"] } +httparse = "1" diff --git a/simple-hyper-client/src/error.rs b/simple-hyper-client/src/error.rs index 2b22878..3a015f0 100644 --- a/simple-hyper-client/src/error.rs +++ b/simple-hyper-client/src/error.rs @@ -4,11 +4,14 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +use derive_more::{IsVariant, TryUnwrap, Unwrap}; use hyper::Method; pub type HyperClientError = hyper_util::client::legacy::Error; -#[derive(Debug, thiserror::Error)] +#[derive(Debug, thiserror::Error, IsVariant, TryUnwrap, Unwrap)] +#[try_unwrap(ref)] +#[unwrap(ref)] pub enum Error { #[error(transparent)] Http(#[from] http::Error), From d7fc726333eac18ca804c15bded47ad4f8fa4028 Mon Sep 17 00:00:00 2001 From: Pieter Agten Date: Mon, 22 Jun 2026 10:17:11 +0200 Subject: [PATCH 5/5] Add test cases for GET requests with/without bodies --- Cargo.lock | 34 +++++++++++++++++++++++++ simple-hyper-client/Cargo.toml | 1 + simple-hyper-client/src/async_client.rs | 26 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 92a8117..5760d54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1061,6 +1061,7 @@ dependencies = [ "httparse", "hyper", "hyper-util", + "test-case", "thiserror", "tokio", "tower-service", @@ -1142,6 +1143,39 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "test-case-core", +] + [[package]] name = "thiserror" version = "2.0.18" diff --git a/simple-hyper-client/Cargo.toml b/simple-hyper-client/Cargo.toml index 2889fd3..1c572da 100644 --- a/simple-hyper-client/Cargo.toml +++ b/simple-hyper-client/Cargo.toml @@ -30,3 +30,4 @@ tower-service = "0.3" futures-util = "0.3" http-body-util = { version = "0.1", features = ["channel"] } httparse = "1" +test-case = "3" diff --git a/simple-hyper-client/src/async_client.rs b/simple-hyper-client/src/async_client.rs index 50ace8b..9cbbcca 100644 --- a/simple-hyper-client/src/async_client.rs +++ b/simple-hyper-client/src/async_client.rs @@ -296,6 +296,7 @@ mod tests { use headers::{ContentLength, ContentType}; use hyper::StatusCode; use std::net::SocketAddr; + use test_case::test_case; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::sync::oneshot; @@ -358,6 +359,31 @@ mod tests { assert_eq!(response_body, "Hello, world!".as_bytes()); } + #[test_case(Some(r#"{"key":"value"}"#.into()), false; "non-empty body not allowed")] + #[test_case(Some("".into()), true; "empty body allowed")] + #[test_case(None, true; "without body allowed")] + #[tokio::test] + async fn get_request(body: Option, expect_ok: bool) { + let (tx, _rx) = oneshot::channel(); + let addr = test_http_server(RESPONSE_OK, tx).await; + let url = format!("http://{}/", addr); + + let connector = HttpConnector::new(); + let client = Client::with_connector(connector); + let mut builder = client.get(url).unwrap(); + + if let Some(body) = body { + builder = builder.header(ContentType::json()).body(body); + } + + let result = builder.send().await; + if expect_ok { + result.unwrap(); + } else { + assert_eq!(result.unwrap_err().unwrap_body_not_allowed(), Method::GET); + } + } + #[tokio::test] async fn drop_client_before_response() { let (tx, _rx) = oneshot::channel();