Brotli support (#791)

This commit is contained in:
EnokMan
2020-02-19 14:49:11 -06:00
committed by GitHub
parent 7622c75064
commit f831d62da0
9 changed files with 400 additions and 103 deletions

View File

@@ -60,6 +60,7 @@ jobs:
- "feat.: cookies" - "feat.: cookies"
- "feat.: blocking" - "feat.: blocking"
- "feat.: gzip" - "feat.: gzip"
- "feat.: brotli"
- "feat.: json" - "feat.: json"
- "feat.: stream" - "feat.: stream"
- "feat.: socks/default-tls" - "feat.: socks/default-tls"
@@ -82,21 +83,21 @@ jobs:
- name: windows / stable-x86_64-msvc - name: windows / stable-x86_64-msvc
os: windows-latest os: windows-latest
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
features: "--features blocking,gzip,json" features: "--features blocking,gzip,brotli,json"
- name: windows / stable-i686-msvc - name: windows / stable-i686-msvc
os: windows-latest os: windows-latest
target: i686-pc-windows-msvc target: i686-pc-windows-msvc
features: "--features blocking,gzip,json" features: "--features blocking,gzip,brotli,json"
- name: windows / stable-x86_64-gnu - name: windows / stable-x86_64-gnu
os: windows-latest os: windows-latest
rust: stable-x86_64-pc-windows-gnu rust: stable-x86_64-pc-windows-gnu
target: 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 - name: windows / stable-i686-gnu
os: windows-latest os: windows-latest
rust: stable-i686-pc-windows-gnu rust: stable-i686-pc-windows-gnu
target: 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" - name: "feat.: default-tls disabled"
features: "--no-default-features" features: "--no-default-features"
@@ -112,6 +113,8 @@ jobs:
features: "--features blocking" features: "--features blocking"
- name: "feat.: gzip" - name: "feat.: gzip"
features: "--features gzip" features: "--features gzip"
- name: "feat.: brotli"
features: "--features brotli"
- name: "feat.: json" - name: "feat.: json"
features: "--features json" features: "--features json"
- name: "feat.: stream" - name: "feat.: stream"

View File

@@ -39,7 +39,9 @@ blocking = ["futures-util/io", "tokio/rt-threaded", "tokio/rt-core", "tokio/sync
cookies = ["cookie_crate", "cookie_store"] cookies = ["cookie_crate", "cookie_store"]
gzip = ["async-compression"] gzip = ["async-compression", "async-compression/gzip"]
brotli = ["async-compression", "async-compression/brotli"]
json = ["serde_json"] json = ["serde_json"]
@@ -104,8 +106,8 @@ webpki-roots = { version = "0.17", optional = true }
cookie_crate = { version = "0.12", package = "cookie", optional = true } cookie_crate = { version = "0.12", package = "cookie", optional = true }
cookie_store = { version = "0.10", optional = true } cookie_store = { version = "0.10", optional = true }
## gzip ## compression
async-compression = { version = "0.2.0", default-features = false, features = ["gzip", "stream"], optional = true } async-compression = { version = "0.3.0", default-features = false, features = ["stream"], optional = true }
## socks ## socks
@@ -119,6 +121,7 @@ env_logger = "0.6"
hyper = { version = "0.13", default-features = false, features = ["tcp", "stream"] } hyper = { version = "0.13", default-features = false, features = ["tcp", "stream"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
libflate = "0.1" libflate = "0.1"
brotli_crate = { package = "brotli", version = "3.3.0" }
doc-comment = "0.3" doc-comment = "0.3"
tokio = { version = "0.2.0", default-features = false, features = ["macros"] } tokio = { version = "0.2.0", default-features = false, features = ["macros"] }
@@ -177,3 +180,8 @@ required-features = ["cookies"]
name = "gzip" name = "gzip"
path = "tests/gzip.rs" path = "tests/gzip.rs"
required-features = ["gzip"] required-features = ["gzip"]
[[test]]
name = "brotli"
path = "tests/brotli.rs"
required-features = ["brotli"]

View File

@@ -11,8 +11,8 @@ use http::header::{
Entry, HeaderMap, HeaderValue, ACCEPT, ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH, Entry, HeaderMap, HeaderValue, ACCEPT, ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH,
CONTENT_TYPE, LOCATION, PROXY_AUTHORIZATION, RANGE, REFERER, TRANSFER_ENCODING, USER_AGENT, CONTENT_TYPE, LOCATION, PROXY_AUTHORIZATION, RANGE, REFERER, TRANSFER_ENCODING, USER_AGENT,
}; };
use http::Uri;
use http::uri::Scheme; use http::uri::Scheme;
use http::Uri;
use hyper::client::ResponseFuture; use hyper::client::ResponseFuture;
#[cfg(feature = "native-tls-crate")] #[cfg(feature = "native-tls-crate")]
use native_tls_crate::TlsConnector; use native_tls_crate::TlsConnector;
@@ -58,6 +58,7 @@ pub struct ClientBuilder {
struct Config { struct Config {
// NOTE: When adding a new field, update `fmt::Debug for ClientBuilder` // NOTE: When adding a new field, update `fmt::Debug for ClientBuilder`
gzip: bool, gzip: bool,
brotli: bool,
headers: HeaderMap, headers: HeaderMap,
#[cfg(feature = "native-tls")] #[cfg(feature = "native-tls")]
hostname_verification: bool, hostname_verification: bool,
@@ -106,6 +107,7 @@ impl ClientBuilder {
config: Config { config: Config {
error: None, error: None,
gzip: cfg!(feature = "gzip"), gzip: cfg!(feature = "gzip"),
brotli: cfg!(feature = "brotli"),
headers, headers,
#[cfg(feature = "native-tls")] #[cfg(feature = "native-tls")]
hostname_verification: true, hostname_verification: true,
@@ -179,7 +181,6 @@ impl ClientBuilder {
cert.add_to_native_tls(&mut tls); cert.add_to_native_tls(&mut tls);
} }
#[cfg(feature = "native-tls")] #[cfg(feature = "native-tls")]
{ {
if let Some(id) = config.identity { 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 { 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); 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); builder.http2_initial_connection_window_size(http2_initial_connection_window_size);
} }
@@ -265,6 +268,7 @@ impl ClientBuilder {
#[cfg(feature = "cookies")] #[cfg(feature = "cookies")]
cookie_store: config.cookie_store.map(RwLock::new), cookie_store: config.cookie_store.map(RwLock::new),
gzip: config.gzip, gzip: config.gzip,
brotli: config.brotli,
hyper: hyper_client, hyper: hyper_client,
headers: config.headers, headers: config.headers,
redirect_policy: config.redirect_policy, redirect_policy: config.redirect_policy,
@@ -278,7 +282,6 @@ impl ClientBuilder {
// Higher-level options // Higher-level options
/// Sets the `User-Agent` header to be used by this client. /// Sets the `User-Agent` header to be used by this client.
/// ///
/// # Example /// # Example
@@ -360,7 +363,6 @@ impl ClientBuilder {
self self
} }
/// Enable a persistent cookie store for the client. /// Enable a persistent cookie store for the client.
/// ///
/// Cookies received in responses will be preserved and included in /// 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. /// 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 /// - 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`. /// an `Accept-Encoding` **and** `Range` values, the `Accept-Encoding` header is set to `gzip`.
@@ -403,6 +405,28 @@ impl ClientBuilder {
self 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. /// Disable auto response body gzip decompression.
/// ///
/// This method exists even if the optional `gzip` feature is not enabled. /// 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 // Redirect options
/// Set a `RedirectPolicy` for this client. /// Set a `RedirectPolicy` for this client.
@@ -534,7 +575,10 @@ impl ClientBuilder {
/// Sets the max connection-level flow control for HTTP2 /// Sets the max connection-level flow control for HTTP2
/// ///
/// Default is currently 65,535 but may change internally to optimize for common uses. /// 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.config.http2_initial_connection_window_size = sz.into();
self self
} }
@@ -654,7 +698,6 @@ impl ClientBuilder {
self self
} }
/// Force using the Rustls TLS backend. /// Force using the Rustls TLS backend.
/// ///
/// Since multiple TLS backends can be optionally enabled, this option will /// 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); let uri = expect_uri(&url);
@@ -834,7 +889,6 @@ impl Client {
.or(self.inner.request_timeout) .or(self.inner.request_timeout)
.map(|dur| tokio::time::delay_for(dur)); .map(|dur| tokio::time::delay_for(dur));
*req.headers_mut() = headers.clone(); *req.headers_mut() = headers.clone();
let in_flight = self.inner.hyper.request(req); let in_flight = self.inner.hyper.request(req);
@@ -913,6 +967,7 @@ impl Config {
} }
f.field("gzip", &self.gzip); f.field("gzip", &self.gzip);
f.field("brotli", &self.brotli);
if !self.proxies.is_empty() { if !self.proxies.is_empty() {
f.field("proxies", &self.proxies); f.field("proxies", &self.proxies);
@@ -977,6 +1032,7 @@ struct ClientRef {
#[cfg(feature = "cookies")] #[cfg(feature = "cookies")]
cookie_store: Option<RwLock<cookie::CookieStore>>, cookie_store: Option<RwLock<cookie::CookieStore>>,
gzip: bool, gzip: bool,
brotli: bool,
headers: HeaderMap, headers: HeaderMap,
hyper: HyperClient, hyper: HyperClient,
redirect_policy: redirect::Policy, redirect_policy: redirect::Policy,
@@ -999,6 +1055,7 @@ impl ClientRef {
} }
f.field("gzip", &self.gzip); f.field("gzip", &self.gzip);
f.field("brotli", &self.brotli);
if !self.proxies.is_empty() { if !self.proxies.is_empty() {
f.field("proxies", &self.proxies); f.field("proxies", &self.proxies);
@@ -1014,15 +1071,12 @@ impl ClientRef {
f.field("default_headers", &self.headers); f.field("default_headers", &self.headers);
if let Some(ref d) = self.request_timeout { if let Some(ref d) = self.request_timeout {
f.field("timeout", d); f.field("timeout", d);
} }
} }
} }
pub(super) struct Pending { pub(super) struct Pending {
inner: PendingInner, inner: PendingInner,
} }
@@ -1227,17 +1281,20 @@ impl Future for PendingRequest {
debug!("redirect policy disallowed redirection to '{}'", loc); debug!("redirect policy disallowed redirection to '{}'", loc);
} }
redirect::ActionKind::Error(err) => { redirect::ActionKind::Error(err) => {
return Poll::Ready(Err(crate::error::redirect( return Poll::Ready(Err(crate::error::redirect(err, self.url.clone())));
err,
self.url.clone(),
)));
} }
} }
} }
} }
debug!("response '{}' for {}", res.status(), self.url); 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)); return Poll::Ready(Ok(res));
} }
} }

View File

@@ -1,62 +1,21 @@
pub(crate) use self::imp::Decoder; 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 { mod imp {
use std::fmt;
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
use std::task::{Context, Poll}; use std::task::{Context, Poll};
use std::{fmt, mem};
#[cfg(feature = "gzip")]
use async_compression::stream::GzipDecoder; use async_compression::stream::GzipDecoder;
#[cfg(feature = "brotli")]
use async_compression::stream::BrotliDecoder;
use bytes::Bytes; use bytes::Bytes;
use futures_core::Stream; use futures_core::Stream;
use futures_util::stream::Peekable; use futures_util::stream::Peekable;
use http::header::{CONTENT_ENCODING, CONTENT_LENGTH, TRANSFER_ENCODING};
use http::HeaderMap; use http::HeaderMap;
use log::warn;
use super::super::Body; use super::super::Body;
use crate::error; use crate::error;
@@ -68,17 +27,32 @@ mod imp {
inner: Inner, inner: Inner,
} }
enum DecoderType {
#[cfg(feature = "gzip")]
Gzip,
#[cfg(feature = "brotli")]
Brotli,
}
enum Inner { enum Inner {
/// A `PlainText` decoder just returns the response content as is. /// A `PlainText` decoder just returns the response content as is.
PlainText(super::super::body::ImplStream), PlainText(super::super::body::ImplStream),
/// A `Gzip` decoder will uncompress the gzipped response content before returning it. /// A `Gzip` decoder will uncompress the gzipped response content before returning it.
#[cfg(feature = "gzip")]
Gzip(GzipDecoder<Peekable<IoStream>>), 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. /// A decoder that doesn't have a value yet.
#[cfg(any(feature = "brotli", feature = "gzip"))]
Pending(Pending), Pending(Pending),
} }
/// A future attempt to poll the response body for EOF so we know whether to use gzip or not. /// 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); struct IoStream(super::super::body::ImplStream);
@@ -108,24 +82,38 @@ mod imp {
/// A gzip decoder. /// A gzip decoder.
/// ///
/// This decoder will buffer and decompress chunks that are gzipped. /// This decoder will buffer and decompress chunks that are gzipped.
#[cfg(feature = "gzip")]
fn gzip(body: Body) -> Decoder { fn gzip(body: Body) -> Decoder {
use futures_util::StreamExt; use futures_util::StreamExt;
Decoder { 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 /// This decoder will buffer and decompress chunks that are brotlied.
/// how to decode the content body of the request. #[cfg(feature = "brotli")]
/// fn brotli(body: Body) -> Decoder {
/// Uses the correct variant by inspecting the Content-Encoding header. use futures_util::StreamExt;
pub(crate) fn detect(headers: &mut HeaderMap, body: Body, check_gzip: bool) -> Decoder {
if !check_gzip { Decoder {
return Decoder::plain_text(body); 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 content_encoding_gzip: bool;
let mut is_gzip = { let mut is_gzip = {
content_encoding_gzip = headers content_encoding_gzip = headers
@@ -146,15 +134,76 @@ mod imp {
} }
} }
} }
if content_encoding_gzip { if is_gzip {
headers.remove(CONTENT_ENCODING); headers.remove(CONTENT_ENCODING);
headers.remove(CONTENT_LENGTH); headers.remove(CONTENT_LENGTH);
} }
if is_gzip { is_gzip
Decoder::gzip(body) }
} else {
Decoder::plain_text(body) #[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>> { 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. // 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) { 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)) => { 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, Poll::Pending => return Poll::Pending,
}, },
Inner::PlainText(ref mut body) => return Pin::new(body).poll_next(cx), Inner::PlainText(ref mut body) => return Pin::new(body).poll_next(cx),
#[cfg(feature = "gzip")]
Inner::Gzip(ref mut decoder) => { Inner::Gzip(ref mut decoder) => {
return match futures_core::ready!(Pin::new(decoder).poll_next(cx)) { return match futures_core::ready!(Pin::new(decoder).poll_next(cx)) {
Some(Ok(bytes)) => Poll::Ready(Some(Ok(bytes))), Some(Ok(bytes)) => Poll::Ready(Some(Ok(bytes))),
Some(Err(err)) => Poll::Ready(Some(Err(crate::error::decode_io(err)))), Some(Err(err)) => Poll::Ready(Some(Err(crate::error::decode_io(err)))),
None => Poll::Ready(None), 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()))), None => return Poll::Ready(Ok(Inner::PlainText(Body::empty().into_stream()))),
}; };
let body = mem::replace( let _body = std::mem::replace(
&mut self.0, &mut self.0,
IoStream(Body::empty().into_stream()).peekable(), 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)))),
}
} }
} }

View File

@@ -39,6 +39,7 @@ impl Response {
res: hyper::Response<hyper::Body>, res: hyper::Response<hyper::Body>,
url: Url, url: Url,
gzip: bool, gzip: bool,
brotli: bool,
timeout: Option<Delay>, timeout: Option<Delay>,
) -> Response { ) -> Response {
let (parts, body) = res.into_parts(); let (parts, body) = res.into_parts();
@@ -47,7 +48,7 @@ impl Response {
let extensions = parts.extensions; let extensions = parts.extensions;
let mut headers = parts.headers; 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 { Response {
status, status,
@@ -404,7 +405,7 @@ impl<T: Into<Body>> From<http::Response<T>> for Response {
fn from(r: http::Response<T>) -> Response { fn from(r: http::Response<T>) -> Response {
let (mut parts, body) = r.into_parts(); let (mut parts, body) = r.into_parts();
let body = body.into(); 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 let url = parts
.extensions .extensions
.remove::<ResponseUrl>() .remove::<ResponseUrl>()
@@ -440,7 +441,6 @@ pub trait ResponseBuilderExt {
fn url(self, url: Url) -> Self; fn url(self, url: Url) -> Self;
} }
impl ResponseBuilderExt for http::response::Builder { impl ResponseBuilderExt for http::response::Builder {
fn url(self, url: Url) -> Self { fn url(self, url: Url) -> Self {
self.extension(ResponseUrl(url)) self.extension(ResponseUrl(url))

View File

@@ -173,6 +173,7 @@
//! - **blocking**: Provides the [blocking][] client API. //! - **blocking**: Provides the [blocking][] client API.
//! - **cookies**: Provides cookie session support. //! - **cookies**: Provides cookie session support.
//! - **gzip**: Provides response body gzip decompression. //! - **gzip**: Provides response body gzip decompression.
//! - **brotli**: Provides response body brotli decompression.
//! - **json**: Provides serialization and deserialization for JSON bodies. //! - **json**: Provides serialization and deserialization for JSON bodies.
//! - **stream**: Adds support for `futures::Stream`. //! - **stream**: Adds support for `futures::Stream`.
//! - **socks**: Provides SOCKS5 proxy support. //! - **socks**: Provides SOCKS5 proxy support.

148
tests/brotli.rs Normal file
View File

@@ -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);
}

View File

@@ -12,7 +12,16 @@ async fn auto_headers() {
assert_eq!(req.headers()["accept"], "*/*"); assert_eq!(req.headers()["accept"], "*/*");
assert_eq!(req.headers().get("user-agent"), None); assert_eq!(req.headers().get("user-agent"), None);
if cfg!(feature = "gzip") { 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() http::Response::default()

View File

@@ -41,7 +41,10 @@ async fn test_gzip_empty_body() {
async fn test_accept_header_is_not_changed_if_set() { async fn test_accept_header_is_not_changed_if_set() {
let server = server::http(move |req| async move { let server = server::http(move |req| async move {
assert_eq!(req.headers()["accept"], "application/json"); 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() http::Response::default()
}); });
@@ -111,7 +114,10 @@ async fn gzip_case(response_size: usize, chunk_size: usize) {
response.extend(&gzipped_content); response.extend(&gzipped_content);
let server = server::http(move |req| { 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(); let gzipped = gzipped_content.clone();
async move { async move {