diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68aaf78..6e3f713 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,7 @@ jobs: - "feat.: cookies" - "feat.: blocking" - "feat.: gzip" + - "feat.: brotli" - "feat.: json" - "feat.: stream" - "feat.: socks/default-tls" @@ -82,21 +83,21 @@ jobs: - name: windows / stable-x86_64-msvc os: windows-latest target: x86_64-pc-windows-msvc - features: "--features blocking,gzip,json" + features: "--features blocking,gzip,brotli,json" - name: windows / stable-i686-msvc os: windows-latest target: i686-pc-windows-msvc - features: "--features blocking,gzip,json" + features: "--features blocking,gzip,brotli,json" - name: windows / stable-x86_64-gnu os: windows-latest rust: stable-x86_64-pc-windows-gnu target: x86_64-pc-windows-gnu - features: "--features blocking,gzip,json" + features: "--features blocking,gzip,brotli,json" - name: windows / stable-i686-gnu os: windows-latest rust: stable-i686-pc-windows-gnu target: i686-pc-windows-gnu - features: "--features blocking,gzip,json" + features: "--features blocking,gzip,brotli,json" - name: "feat.: default-tls disabled" features: "--no-default-features" @@ -112,6 +113,8 @@ jobs: features: "--features blocking" - name: "feat.: gzip" features: "--features gzip" + - name: "feat.: brotli" + features: "--features brotli" - name: "feat.: json" features: "--features json" - name: "feat.: stream" diff --git a/Cargo.toml b/Cargo.toml index 43d9169..3e747db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,9 @@ blocking = ["futures-util/io", "tokio/rt-threaded", "tokio/rt-core", "tokio/sync cookies = ["cookie_crate", "cookie_store"] -gzip = ["async-compression"] +gzip = ["async-compression", "async-compression/gzip"] + +brotli = ["async-compression", "async-compression/brotli"] json = ["serde_json"] @@ -104,8 +106,8 @@ webpki-roots = { version = "0.17", optional = true } cookie_crate = { version = "0.12", package = "cookie", optional = true } cookie_store = { version = "0.10", optional = true } -## gzip -async-compression = { version = "0.2.0", default-features = false, features = ["gzip", "stream"], optional = true } +## compression +async-compression = { version = "0.3.0", default-features = false, features = ["stream"], optional = true } ## socks @@ -119,6 +121,7 @@ env_logger = "0.6" hyper = { version = "0.13", default-features = false, features = ["tcp", "stream"] } serde = { version = "1.0", features = ["derive"] } libflate = "0.1" +brotli_crate = { package = "brotli", version = "3.3.0" } doc-comment = "0.3" tokio = { version = "0.2.0", default-features = false, features = ["macros"] } @@ -177,3 +180,8 @@ required-features = ["cookies"] name = "gzip" path = "tests/gzip.rs" required-features = ["gzip"] + +[[test]] +name = "brotli" +path = "tests/brotli.rs" +required-features = ["brotli"] \ No newline at end of file diff --git a/src/async_impl/client.rs b/src/async_impl/client.rs index 231953e..0552537 100644 --- a/src/async_impl/client.rs +++ b/src/async_impl/client.rs @@ -11,8 +11,8 @@ use http::header::{ Entry, HeaderMap, HeaderValue, ACCEPT, ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, LOCATION, PROXY_AUTHORIZATION, RANGE, REFERER, TRANSFER_ENCODING, USER_AGENT, }; -use http::Uri; use http::uri::Scheme; +use http::Uri; use hyper::client::ResponseFuture; #[cfg(feature = "native-tls-crate")] use native_tls_crate::TlsConnector; @@ -58,6 +58,7 @@ pub struct ClientBuilder { struct Config { // NOTE: When adding a new field, update `fmt::Debug for ClientBuilder` gzip: bool, + brotli: bool, headers: HeaderMap, #[cfg(feature = "native-tls")] hostname_verification: bool, @@ -106,6 +107,7 @@ impl ClientBuilder { config: Config { error: None, gzip: cfg!(feature = "gzip"), + brotli: cfg!(feature = "brotli"), headers, #[cfg(feature = "native-tls")] hostname_verification: true, @@ -179,7 +181,6 @@ impl ClientBuilder { cert.add_to_native_tls(&mut tls); } - #[cfg(feature = "native-tls")] { if let Some(id) = config.identity { @@ -246,7 +247,9 @@ impl ClientBuilder { if let Some(http2_initial_stream_window_size) = config.http2_initial_stream_window_size { builder.http2_initial_stream_window_size(http2_initial_stream_window_size); } - if let Some(http2_initial_connection_window_size) = config.http2_initial_connection_window_size { + if let Some(http2_initial_connection_window_size) = + config.http2_initial_connection_window_size + { builder.http2_initial_connection_window_size(http2_initial_connection_window_size); } @@ -265,6 +268,7 @@ impl ClientBuilder { #[cfg(feature = "cookies")] cookie_store: config.cookie_store.map(RwLock::new), gzip: config.gzip, + brotli: config.brotli, hyper: hyper_client, headers: config.headers, redirect_policy: config.redirect_policy, @@ -278,7 +282,6 @@ impl ClientBuilder { // Higher-level options - /// Sets the `User-Agent` header to be used by this client. /// /// # Example @@ -360,7 +363,6 @@ impl ClientBuilder { self } - /// Enable a persistent cookie store for the client. /// /// Cookies received in responses will be preserved and included in @@ -383,7 +385,7 @@ impl ClientBuilder { /// Enable auto gzip decompression by checking the `Content-Encoding` response header. /// - /// If auto gzip decompresson is turned on: + /// If auto gzip decompression is turned on: /// /// - When sending a request and if the request's headers do not already contain /// an `Accept-Encoding` **and** `Range` values, the `Accept-Encoding` header is set to `gzip`. @@ -403,6 +405,28 @@ impl ClientBuilder { self } + /// Enable auto brotli decompression by checking the `Content-Encoding` response header. + /// + /// If auto brotli decompression is turned on: + /// + /// - When sending a request and if the request's headers do not already contain + /// an `Accept-Encoding` **and** `Range` values, the `Accept-Encoding` header is set to `br`. + /// The request body is **not** automatically compressed. + /// - When receiving a response, if it's headers contain a `Content-Encoding` value that + /// equals to `br`, both values `Content-Encoding` and `Content-Length` are removed from the + /// headers' set. The response body is automatically decompressed. + /// + /// If the `brotli` feature is turned on, the default option is enabled. + /// + /// # Optional + /// + /// This requires the optional `brotli` feature to be enabled + #[cfg(feature = "brotli")] + pub fn brotli(mut self, enable: bool) -> ClientBuilder { + self.config.brotli = enable; + self + } + /// Disable auto response body gzip decompression. /// /// This method exists even if the optional `gzip` feature is not enabled. @@ -420,6 +444,23 @@ impl ClientBuilder { } } + /// Disable auto response body brotli decompression. + /// + /// This method exists even if the optional `brotli` feature is not enabled. + /// This can be used to ensure a `Client` doesn't use brotli decompression + /// even if another dependency were to enable the optional `brotli` feature. + pub fn no_brotli(self) -> ClientBuilder { + #[cfg(feature = "brotli")] + { + self.brotli(false) + } + + #[cfg(not(feature = "brotli"))] + { + self + } + } + // Redirect options /// Set a `RedirectPolicy` for this client. @@ -534,7 +575,10 @@ impl ClientBuilder { /// Sets the max connection-level flow control for HTTP2 /// /// Default is currently 65,535 but may change internally to optimize for common uses. - pub fn http2_initial_connection_window_size(mut self, sz: impl Into>) -> ClientBuilder { + pub fn http2_initial_connection_window_size( + mut self, + sz: impl Into>, + ) -> ClientBuilder { self.config.http2_initial_connection_window_size = sz.into(); self } @@ -654,7 +698,6 @@ impl ClientBuilder { self } - /// Force using the Rustls TLS backend. /// /// Since multiple TLS backends can be optionally enabled, this option will @@ -807,9 +850,21 @@ impl Client { } } - if self.inner.gzip && !headers.contains_key(ACCEPT_ENCODING) && !headers.contains_key(RANGE) + let accept_encoding = match (self.inner.gzip, self.inner.brotli) { + (true, true) => Some("gzip, br"), + (true, false) => Some("gzip"), + (false, true) => Some("br"), + _ => None, + }; + + if accept_encoding.is_some() + && !headers.contains_key(ACCEPT_ENCODING) + && !headers.contains_key(RANGE) { - headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("gzip")); + headers.insert( + ACCEPT_ENCODING, + HeaderValue::from_static(accept_encoding.unwrap()), + ); } let uri = expect_uri(&url); @@ -834,7 +889,6 @@ impl Client { .or(self.inner.request_timeout) .map(|dur| tokio::time::delay_for(dur)); - *req.headers_mut() = headers.clone(); let in_flight = self.inner.hyper.request(req); @@ -913,6 +967,7 @@ impl Config { } f.field("gzip", &self.gzip); + f.field("brotli", &self.brotli); if !self.proxies.is_empty() { f.field("proxies", &self.proxies); @@ -977,6 +1032,7 @@ struct ClientRef { #[cfg(feature = "cookies")] cookie_store: Option>, gzip: bool, + brotli: bool, headers: HeaderMap, hyper: HyperClient, redirect_policy: redirect::Policy, @@ -999,6 +1055,7 @@ impl ClientRef { } f.field("gzip", &self.gzip); + f.field("brotli", &self.brotli); if !self.proxies.is_empty() { f.field("proxies", &self.proxies); @@ -1014,15 +1071,12 @@ impl ClientRef { f.field("default_headers", &self.headers); - if let Some(ref d) = self.request_timeout { f.field("timeout", d); } } } - - pub(super) struct Pending { inner: PendingInner, } @@ -1227,17 +1281,20 @@ impl Future for PendingRequest { debug!("redirect policy disallowed redirection to '{}'", loc); } redirect::ActionKind::Error(err) => { - return Poll::Ready(Err(crate::error::redirect( - err, - self.url.clone(), - ))); + return Poll::Ready(Err(crate::error::redirect(err, self.url.clone()))); } } } } debug!("response '{}' for {}", res.status(), self.url); - let res = Response::new(res, self.url.clone(), self.client.gzip, self.timeout.take()); + let res = Response::new( + res, + self.url.clone(), + self.client.gzip, + self.client.brotli, + self.timeout.take(), + ); return Poll::Ready(Ok(res)); } } diff --git a/src/async_impl/decoder.rs b/src/async_impl/decoder.rs index 74c5c9c..15d1fca 100644 --- a/src/async_impl/decoder.rs +++ b/src/async_impl/decoder.rs @@ -1,62 +1,21 @@ pub(crate) use self::imp::Decoder; -#[cfg(not(feature = "gzip"))] -mod imp { - use std::pin::Pin; - use std::task::{Context, Poll}; - - use bytes::Bytes; - use futures_core::Stream; - use http::HeaderMap; - - use super::super::Body; - pub(crate) struct Decoder { - inner: super::super::body::ImplStream, - } - - impl Decoder { - #[cfg(feature = "blocking")] - pub(crate) fn empty() -> Decoder { - Decoder::plain_text(Body::empty()) - } - - /// A plain text decoder. - /// - /// This decoder will emit the underlying chunks as-is. - fn plain_text(body: Body) -> Decoder { - Decoder { - inner: body.into_stream(), - } - } - - pub(crate) fn detect(_: &mut HeaderMap, body: Body, _: bool) -> Decoder { - Decoder::plain_text(body) - } - } - - impl Stream for Decoder { - type Item = crate::Result; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll> { - Pin::new(&mut self.inner).poll_next(cx) - } - } -} - -#[cfg(feature = "gzip")] mod imp { + use std::fmt; use std::future::Future; use std::pin::Pin; use std::task::{Context, Poll}; - use std::{fmt, mem}; + #[cfg(feature = "gzip")] use async_compression::stream::GzipDecoder; + + #[cfg(feature = "brotli")] + use async_compression::stream::BrotliDecoder; + use bytes::Bytes; use futures_core::Stream; use futures_util::stream::Peekable; - use http::header::{CONTENT_ENCODING, CONTENT_LENGTH, TRANSFER_ENCODING}; use http::HeaderMap; - use log::warn; use super::super::Body; use crate::error; @@ -68,17 +27,32 @@ mod imp { inner: Inner, } + enum DecoderType { + #[cfg(feature = "gzip")] + Gzip, + #[cfg(feature = "brotli")] + Brotli, + } + enum Inner { /// A `PlainText` decoder just returns the response content as is. PlainText(super::super::body::ImplStream), + /// A `Gzip` decoder will uncompress the gzipped response content before returning it. + #[cfg(feature = "gzip")] Gzip(GzipDecoder>), + + /// A `Brotli` decoder will uncompress the brotlied response content before returning it. + #[cfg(feature = "brotli")] + Brotli(BrotliDecoder>), + /// A decoder that doesn't have a value yet. + #[cfg(any(feature = "brotli", feature = "gzip"))] Pending(Pending), } /// A future attempt to poll the response body for EOF so we know whether to use gzip or not. - struct Pending(Peekable); + struct Pending(Peekable, DecoderType); struct IoStream(super::super::body::ImplStream); @@ -108,24 +82,38 @@ mod imp { /// A gzip decoder. /// /// This decoder will buffer and decompress chunks that are gzipped. + #[cfg(feature = "gzip")] fn gzip(body: Body) -> Decoder { use futures_util::StreamExt; Decoder { - inner: Inner::Pending(Pending(IoStream(body.into_stream()).peekable())), + inner: Inner::Pending(Pending( + IoStream(body.into_stream()).peekable(), + DecoderType::Gzip, + )), } } - /// Constructs a Decoder from a hyper request. + /// A brotli decoder. /// - /// A decoder is just a wrapper around the hyper request that knows - /// how to decode the content body of the request. - /// - /// Uses the correct variant by inspecting the Content-Encoding header. - pub(crate) fn detect(headers: &mut HeaderMap, body: Body, check_gzip: bool) -> Decoder { - if !check_gzip { - return Decoder::plain_text(body); + /// This decoder will buffer and decompress chunks that are brotlied. + #[cfg(feature = "brotli")] + fn brotli(body: Body) -> Decoder { + use futures_util::StreamExt; + + Decoder { + inner: Inner::Pending(Pending( + IoStream(body.into_stream()).peekable(), + DecoderType::Brotli, + )), } + } + + #[cfg(feature = "gzip")] + fn detect_gzip(headers: &mut HeaderMap) -> bool { + use http::header::{CONTENT_ENCODING, CONTENT_LENGTH, TRANSFER_ENCODING}; + use log::warn; + let content_encoding_gzip: bool; let mut is_gzip = { content_encoding_gzip = headers @@ -146,15 +134,76 @@ mod imp { } } } - if content_encoding_gzip { + if is_gzip { headers.remove(CONTENT_ENCODING); headers.remove(CONTENT_LENGTH); } - if is_gzip { - Decoder::gzip(body) - } else { - Decoder::plain_text(body) + is_gzip + } + + #[cfg(feature = "brotli")] + fn detect_brotli(headers: &mut HeaderMap) -> bool { + use http::header::{CONTENT_ENCODING, CONTENT_LENGTH, TRANSFER_ENCODING}; + use log::warn; + + let content_encoding_gzip: bool; + let mut is_brotli = { + content_encoding_gzip = headers + .get_all(CONTENT_ENCODING) + .iter() + .any(|enc| enc == "br"); + content_encoding_gzip + || headers + .get_all(TRANSFER_ENCODING) + .iter() + .any(|enc| enc == "br") + }; + if is_brotli { + if let Some(content_length) = headers.get(CONTENT_LENGTH) { + if content_length == "0" { + warn!("brotli response with content-length of 0"); + is_brotli = false; + } + } } + if is_brotli { + headers.remove(CONTENT_ENCODING); + headers.remove(CONTENT_LENGTH); + } + is_brotli + } + + /// Constructs a Decoder from a hyper request. + /// + /// A decoder is just a wrapper around the hyper request that knows + /// how to decode the content body of the request. + /// + /// Uses the correct variant by inspecting the Content-Encoding header. + pub(crate) fn detect( + _headers: &mut HeaderMap, + body: Body, + check_gzip: bool, + check_brotli: bool, + ) -> Decoder { + if !check_gzip && !check_brotli { + return Decoder::plain_text(body); + } + + #[cfg(feature = "gzip")] + { + if Decoder::detect_gzip(_headers) { + return Decoder::gzip(body); + } + } + + #[cfg(feature = "brotli")] + { + if Decoder::detect_brotli(_headers) { + return Decoder::brotli(body); + } + } + + Decoder::plain_text(body) } } @@ -163,26 +212,36 @@ mod imp { fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll> { // Do a read or poll for a pending decoder value. - let new_value = match self.inner { + match self.inner { + #[cfg(any(feature = "brotli", feature = "gzip"))] Inner::Pending(ref mut future) => match Pin::new(future).poll(cx) { - Poll::Ready(Ok(inner)) => inner, + Poll::Ready(Ok(inner)) => { + self.inner = inner; + return self.poll_next(cx); + } Poll::Ready(Err(e)) => { - return Poll::Ready(Some(Err(crate::error::decode_io(e)))) + return Poll::Ready(Some(Err(crate::error::decode_io(e)))); } Poll::Pending => return Poll::Pending, }, Inner::PlainText(ref mut body) => return Pin::new(body).poll_next(cx), + #[cfg(feature = "gzip")] Inner::Gzip(ref mut decoder) => { return match futures_core::ready!(Pin::new(decoder).poll_next(cx)) { Some(Ok(bytes)) => Poll::Ready(Some(Ok(bytes))), Some(Err(err)) => Poll::Ready(Some(Err(crate::error::decode_io(err)))), None => Poll::Ready(None), - } + }; + } + #[cfg(feature = "brotli")] + Inner::Brotli(ref mut decoder) => { + return match futures_core::ready!(Pin::new(decoder).poll_next(cx)) { + Some(Ok(bytes)) => Poll::Ready(Some(Ok(bytes))), + Some(Err(err)) => Poll::Ready(Some(Err(crate::error::decode_io(err)))), + None => Poll::Ready(None), + }; } }; - - self.inner = new_value; - self.poll_next(cx) } } @@ -207,11 +266,17 @@ mod imp { None => return Poll::Ready(Ok(Inner::PlainText(Body::empty().into_stream()))), }; - let body = mem::replace( + let _body = std::mem::replace( &mut self.0, IoStream(Body::empty().into_stream()).peekable(), ); - Poll::Ready(Ok(Inner::Gzip(GzipDecoder::new(body)))) + + match self.1 { + #[cfg(feature = "brotli")] + DecoderType::Brotli => Poll::Ready(Ok(Inner::Brotli(BrotliDecoder::new(_body)))), + #[cfg(feature = "gzip")] + DecoderType::Gzip => Poll::Ready(Ok(Inner::Gzip(GzipDecoder::new(_body)))), + } } } diff --git a/src/async_impl/response.rs b/src/async_impl/response.rs index c852110..19c6f6c 100644 --- a/src/async_impl/response.rs +++ b/src/async_impl/response.rs @@ -39,6 +39,7 @@ impl Response { res: hyper::Response, url: Url, gzip: bool, + brotli: bool, timeout: Option, ) -> Response { let (parts, body) = res.into_parts(); @@ -47,7 +48,7 @@ impl Response { let extensions = parts.extensions; let mut headers = parts.headers; - let decoder = Decoder::detect(&mut headers, Body::response(body, timeout), gzip); + let decoder = Decoder::detect(&mut headers, Body::response(body, timeout), gzip, brotli); Response { status, @@ -404,7 +405,7 @@ impl> From> for Response { fn from(r: http::Response) -> Response { let (mut parts, body) = r.into_parts(); let body = body.into(); - let body = Decoder::detect(&mut parts.headers, body, false); + let body = Decoder::detect(&mut parts.headers, body, false, false); let url = parts .extensions .remove::() @@ -440,7 +441,6 @@ pub trait ResponseBuilderExt { fn url(self, url: Url) -> Self; } - impl ResponseBuilderExt for http::response::Builder { fn url(self, url: Url) -> Self { self.extension(ResponseUrl(url)) diff --git a/src/lib.rs b/src/lib.rs index 23a813b..505717e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -173,6 +173,7 @@ //! - **blocking**: Provides the [blocking][] client API. //! - **cookies**: Provides cookie session support. //! - **gzip**: Provides response body gzip decompression. +//! - **brotli**: Provides response body brotli decompression. //! - **json**: Provides serialization and deserialization for JSON bodies. //! - **stream**: Adds support for `futures::Stream`. //! - **socks**: Provides SOCKS5 proxy support. diff --git a/tests/brotli.rs b/tests/brotli.rs new file mode 100644 index 0000000..1b50911 --- /dev/null +++ b/tests/brotli.rs @@ -0,0 +1,148 @@ +mod support; +use std::io::Read; +use support::*; + +#[tokio::test] +async fn brotli_response() { + brotli_case(10_000, 4096).await; +} + +#[tokio::test] +async fn brotli_single_byte_chunks() { + brotli_case(10, 1).await; +} + +#[tokio::test] +async fn test_brotli_empty_body() { + let server = server::http(move |req| async move { + assert_eq!(req.method(), "HEAD"); + + http::Response::builder() + .header("content-encoding", "br") + .header("content-length", 100) + .body(Default::default()) + .unwrap() + }); + + let client = reqwest::Client::new(); + let res = client + .head(&format!("http://{}/brotli", server.addr())) + .send() + .await + .unwrap(); + + let body = res.text().await.unwrap(); + + assert_eq!(body, ""); +} + +#[tokio::test] +async fn test_accept_header_is_not_changed_if_set() { + let server = server::http(move |req| async move { + assert_eq!(req.headers()["accept"], "application/json"); + assert!(req.headers()["accept-encoding"] + .to_str() + .unwrap() + .contains("br")); + http::Response::default() + }); + + let client = reqwest::Client::new(); + + let res = client + .get(&format!("http://{}/accept", server.addr())) + .header( + reqwest::header::ACCEPT, + reqwest::header::HeaderValue::from_static("application/json"), + ) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), reqwest::StatusCode::OK); +} + +#[tokio::test] +async fn test_accept_encoding_header_is_not_changed_if_set() { + let server = server::http(move |req| async move { + assert_eq!(req.headers()["accept"], "*/*"); + assert_eq!(req.headers()["accept-encoding"], "identity"); + http::Response::default() + }); + + let client = reqwest::Client::new(); + + let res = client + .get(&format!("http://{}/accept-encoding", server.addr())) + .header( + reqwest::header::ACCEPT_ENCODING, + reqwest::header::HeaderValue::from_static("identity"), + ) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), reqwest::StatusCode::OK); +} + +async fn brotli_case(response_size: usize, chunk_size: usize) { + use futures_util::stream::StreamExt; + + let content: String = (0..response_size) + .into_iter() + .map(|i| format!("test {}", i)) + .collect(); + + let mut encoder = brotli_crate::CompressorReader::new(content.as_bytes(), 4096, 5, 20); + let mut brotlied_content = Vec::new(); + encoder.read_to_end(&mut brotlied_content).unwrap(); + + let mut response = format!( + "\ + HTTP/1.1 200 OK\r\n\ + Server: test-accept\r\n\ + Content-Encoding: br\r\n\ + Content-Length: {}\r\n\ + \r\n", + &brotlied_content.len() + ) + .into_bytes(); + response.extend(&brotlied_content); + + let server = server::http(move |req| { + assert!(req.headers()["accept-encoding"] + .to_str() + .unwrap() + .contains("br")); + + let brotlied = brotlied_content.clone(); + async move { + let len = brotlied.len(); + let stream = + futures_util::stream::unfold((brotlied, 0), move |(brotlied, pos)| async move { + let chunk = brotlied.chunks(chunk_size).nth(pos)?.to_vec(); + + Some((chunk, (brotlied, pos + 1))) + }); + + let body = hyper::Body::wrap_stream(stream.map(Ok::<_, std::convert::Infallible>)); + + http::Response::builder() + .header("content-encoding", "br") + .header("content-length", len) + .body(body) + .unwrap() + } + }); + + let client = reqwest::Client::new(); + + let res = client + .get(&format!("http://{}/brotli", server.addr())) + .send() + .await + .expect("response"); + + let body = res.text().await.expect("text"); + assert_eq!(body, content); +} diff --git a/tests/client.rs b/tests/client.rs index 01eb20a..478bc9b 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -12,7 +12,16 @@ async fn auto_headers() { assert_eq!(req.headers()["accept"], "*/*"); assert_eq!(req.headers().get("user-agent"), None); if cfg!(feature = "gzip") { - assert_eq!(req.headers()["accept-encoding"], "gzip"); + assert!(req.headers()["accept-encoding"] + .to_str() + .unwrap() + .contains("gzip")); + } + if cfg!(feature = "brotli") { + assert!(req.headers()["accept-encoding"] + .to_str() + .unwrap() + .contains("br")); } http::Response::default() diff --git a/tests/gzip.rs b/tests/gzip.rs index ec072f3..3645f48 100644 --- a/tests/gzip.rs +++ b/tests/gzip.rs @@ -41,7 +41,10 @@ async fn test_gzip_empty_body() { async fn test_accept_header_is_not_changed_if_set() { let server = server::http(move |req| async move { assert_eq!(req.headers()["accept"], "application/json"); - assert_eq!(req.headers()["accept-encoding"], "gzip"); + assert!(req.headers()["accept-encoding"] + .to_str() + .unwrap() + .contains("gzip")); http::Response::default() }); @@ -111,7 +114,10 @@ async fn gzip_case(response_size: usize, chunk_size: usize) { response.extend(&gzipped_content); let server = server::http(move |req| { - assert_eq!(req.headers()["accept-encoding"], "gzip"); + assert!(req.headers()["accept-encoding"] + .to_str() + .unwrap() + .contains("gzip")); let gzipped = gzipped_content.clone(); async move {