Repository: seanmonstar/reqwest (language: rust)

Issue:
Brotli support
Is there any plan to support brotli, or how to implement my own decoder and apply it?


Discussion / hints:
Adding brotli support would be awesome! Would you be interested in sending a PR adding it, similar to how gzip works?

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 68aaf78c5..6e3f71353 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 43d91698d..3e747dbd4 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 231953efc..0552537a5 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<Option<u32>>) -> ClientBuilder {
+    pub fn http2_initial_connection_window_size(
+        mut self,
+        sz: impl Into<Option<u32>>,
+    ) -> 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<RwLock<cookie::CookieStore>>,
     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 74c5c9c2f..15d1fcae6 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<Bytes>;
-
-        fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Option<Self::Item>> {
-            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<Peekable<IoStream>>),
+
+        /// A `Brotli` decoder will uncompress the brotlied response content before returning it.
+        #[cfg(feature = "brotli")]
+        Brotli(BrotliDecoder<Peekable<IoStream>>),
+
         /// 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<IoStream>);
+    struct Pending(Peekable<IoStream>, 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<Option<Self::Item>> {
             // 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 c8521105e..19c6f6c51 100644
--- a/src/async_impl/response.rs
+++ b/src/async_impl/response.rs
@@ -39,6 +39,7 @@ impl Response {
         res: hyper::Response<hyper::Body>,
         url: Url,
         gzip: bool,
+        brotli: bool,
         timeout: Option<Delay>,
     ) -> 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<T: Into<Body>> From<http::Response<T>> for Response {
     fn from(r: http::Response<T>) -> 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::<ResponseUrl>()
@@ -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 23a813b54..505717e32 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.


Test patch:
diff --git a/tests/brotli.rs b/tests/brotli.rs
new file mode 100644
index 000000000..1b50911fb
--- /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 01eb20a29..478bc9b96 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 ec072f321..3645f48ad 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 {