From 6a55aa72536b5f46f5a16e7a3487c0ee8b8ddeaa Mon Sep 17 00:00:00 2001 From: Martin1887 Date: Tue, 2 Oct 2018 19:15:34 +0200 Subject: [PATCH] Add 'compression' contrib module. --- contrib/lib/Cargo.toml | 7 + contrib/lib/src/compression/fairing.rs | 192 ++++++++++++++ contrib/lib/src/compression/mod.rs | 111 ++++++++ contrib/lib/src/compression/responder.rs | 56 +++++ contrib/lib/src/lib.rs | 9 + contrib/lib/tests/compressed.rs | 244 ++++++++++++++++++ contrib/lib/tests/compression.rs | 307 +++++++++++++++++++++++ 7 files changed, 926 insertions(+) create mode 100644 contrib/lib/src/compression/fairing.rs create mode 100644 contrib/lib/src/compression/mod.rs create mode 100644 contrib/lib/src/compression/responder.rs create mode 100644 contrib/lib/tests/compressed.rs create mode 100644 contrib/lib/tests/compression.rs diff --git a/contrib/lib/Cargo.toml b/contrib/lib/Cargo.toml index a7b08f9d..d8db934f 100644 --- a/contrib/lib/Cargo.toml +++ b/contrib/lib/Cargo.toml @@ -23,6 +23,9 @@ tera_templates = ["tera", "templates"] handlebars_templates = ["handlebars", "templates"] helmet = ["time"] serve = [] +compression = ["brotli_compression", "gzip_compression"] +brotli_compression = ["brotli"] +gzip_compression = ["flate2"] # The barage of user-facing database features. diesel_sqlite_pool = ["databases", "diesel/sqlite", "diesel/r2d2"] @@ -76,6 +79,10 @@ r2d2-memcache = { version = "0.3", optional = true } # SpaceHelmet dependencies time = { version = "0.1.40", optional = true } +# Compression dependencies +brotli = { version = "2.5", optional = true } +flate2 = { version = "1.0", optional = true } + [target.'cfg(debug_assertions)'.dependencies] notify = { version = "^4.0.6" } diff --git a/contrib/lib/src/compression/fairing.rs b/contrib/lib/src/compression/fairing.rs new file mode 100644 index 00000000..0303e7e3 --- /dev/null +++ b/contrib/lib/src/compression/fairing.rs @@ -0,0 +1,192 @@ +//! Automatic response compression. +//! +//! See the [`Compression`](compression::fairing::Compression) type for further +//! details. + +use rocket::config::{ConfigError, Value}; +use rocket::fairing::{Fairing, Info, Kind}; +use rocket::http::MediaType; +use rocket::Rocket; +use rocket::{Request, Response}; + +crate use super::CompressionUtils; + +crate struct Context { + crate exclusions: Vec, +} + +impl Context { + crate fn new() -> Context { + Context { + exclusions: vec![ + MediaType::parse_flexible("application/gzip").unwrap(), + MediaType::parse_flexible("application/zip").unwrap(), + MediaType::parse_flexible("image/*").unwrap(), + MediaType::parse_flexible("video/*").unwrap(), + MediaType::parse_flexible("application/wasm").unwrap(), + MediaType::parse_flexible("application/octet-stream").unwrap(), + ], + } + } + crate fn with_exclusions(excls: Vec) -> Context { + Context { exclusions: excls } + } +} + +/// The Compression type implements brotli and gzip compression for responses in +/// accordance with the Accept-Encoding header. If accepted, brotli compression +/// is preferred over gzip. +/// +/// In the brotli compression mode (using the +/// [rust-brotli](https://github.com/dropbox/rust-brotli) crate), quality is set +/// to 2 in order to achieve fast compression with a compression ratio similar +/// to gzip. When appropriate, brotli's text and font compression modes are +/// used. +/// +/// In the gzip compression mode (using the +/// [flate2](https://github.com/alexcrichton/flate2-rs) crate), quality is set +/// to the default (9) in order to have good compression ratio. +/// +/// This fairing does not compress responses that already have a +/// `Content-Encoding` header. +/// +/// This fairing ignores the responses with a `Content-Type` matching any of +/// the following default types: +/// +/// - application/gzip +/// - application/brotli +/// - image/* +/// - video/* +/// - application/wasm +/// - application/octet-stream +/// +/// The excluded types can be changed changing the `compress.exclude` Rocket +/// configuration property. +/// +/// # Usage +/// +/// To use, add the `brotli_compression` feature, the `gzip_compression` +/// feature, or the `compression` feature (to enable both algorithms) to the +/// `rocket_contrib` dependencies section of your `Cargo.toml`: +/// +/// ```toml,ignore +/// [dependencies.rocket_contrib] +/// version = "*" +/// default-features = false +/// features = ["compression"] +/// ``` +/// +/// Then, ensure that the compression [fairing](/rocket/fairing/) is attached to +/// your Rocket application: +/// +/// ```rust +/// extern crate rocket; +/// extern crate rocket_contrib; +/// +/// use rocket_contrib::compression::Compression; +/// +/// fn main() { +/// rocket::ignite() +/// // ... +/// .attach(Compression::fairing()) +/// // ... +/// # ; +/// } +/// ``` +pub struct Compression(()); + +impl Compression { + /// Returns a fairing that compresses outgoing requests. + /// + /// ## Example + /// To attach this fairing, simply call `attach` on the application's + /// `Rocket` instance with `Compression::fairing()`: + /// + /// ```rust + /// extern crate rocket; + /// extern crate rocket_contrib; + /// + /// use rocket_contrib::compression::Compression; + /// + /// fn main() { + /// rocket::ignite() + /// // ... + /// .attach(Compression::fairing()) + /// // ... + /// # ; + /// } + /// ``` + pub fn fairing() -> Compression { + Compression { 0: () } + } +} + +impl Fairing for Compression { + fn info(&self) -> Info { + Info { + name: "Response compression", + kind: Kind::Attach | Kind::Response, + } + } + + fn on_attach(&self, rocket: Rocket) -> Result { + let mut ctxt = Context::new(); + match rocket.config().get_table("compress").and_then(|t| { + t.get("exclude") + .ok_or(ConfigError::Missing(String::from("exclude"))) + }) { + Ok(excls) => match excls.as_array() { + Some(excls) => { + let mut error = false; + let mut exclusions_vec = Vec::with_capacity(excls.len()); + for e in excls { + match e { + Value::String(s) => match MediaType::parse_flexible(s) { + Some(media_type) => exclusions_vec.push(media_type), + None => { + error = true; + warn_!( + "Exclusions must be valid content types, using default compression exclusions '{:?}'", + ctxt.exclusions + ); + break; + } + }, + _ => { + error = true; + warn_!( + "Exclusions must be strings, using default compression exclusions '{:?}'", + ctxt.exclusions + ); + break; + } + } + } + if !error { + ctxt = Context::with_exclusions(exclusions_vec); + } + } + None => { + warn_!( + "Exclusions must be an array of strings, using default compression exclusions '{:?}'", + ctxt.exclusions + ); + } + }, + Err(ConfigError::Missing(_)) => { /* ignore missing */ } + Err(e) => { + e.pretty_print(); + warn_!( + "Using default compression exclusions '{:?}'", + ctxt.exclusions + ); + } + }; + + Ok(rocket.manage(ctxt)) + } + + fn on_response(&self, request: &Request, response: &mut Response) { + CompressionUtils::compress_response(request, response, true); + } +} diff --git a/contrib/lib/src/compression/mod.rs b/contrib/lib/src/compression/mod.rs new file mode 100644 index 00000000..22d52f5e --- /dev/null +++ b/contrib/lib/src/compression/mod.rs @@ -0,0 +1,111 @@ +//! `Compression` fairing and `Compressed` responder to automatically and +//! on demand respectively compressing responses. +mod fairing; +mod responder; + +pub use self::fairing::Compression; +pub use self::responder::Compressed; + +crate use self::fairing::Context; +use rocket::http::hyper::header::{ContentEncoding, Encoding}; +use rocket::{Request, Response}; +use std::io::Read; + +#[cfg(feature = "brotli_compression")] +use brotli::enc::backward_references::BrotliEncoderMode; + +#[cfg(feature = "gzip_compression")] +use flate2::read::GzEncoder; + +crate struct CompressionUtils; + +impl CompressionUtils { + fn accepts_encoding(request: &Request, encoding: &str) -> bool { + request + .headers() + .get("Accept-Encoding") + .flat_map(|accept| accept.split(",")) + .map(|accept| accept.trim()) + .any(|accept| accept == encoding) + } + + fn already_encoded(response: &Response) -> bool { + response.headers().get("Content-Encoding").next().is_some() + } + + fn set_body_and_encoding<'r, B: Read + 'r>( + response: &mut Response<'r>, + body: B, + encoding: Encoding, + ) { + response.set_header(ContentEncoding(vec![encoding])); + response.set_streamed_body(body); + } + + fn skip_encoding( + content_type: &Option, + context: &rocket::State, + ) -> bool { + match content_type { + Some(content_type) => context.exclusions.iter().any(|exc_media_type| { + if exc_media_type.sub() == "*" { + *exc_media_type.top() == *content_type.top() + } else { + *exc_media_type == *content_type.media_type() + } + }), + None => false, + } + } + + fn compress_response(request: &Request, response: &mut Response, respect_excludes: bool) { + if CompressionUtils::already_encoded(response) { + return; + } + + let content_type = response.content_type(); + + if respect_excludes { + let context = request + .guard::<::rocket::State>() + .expect("Compression Context registered in on_attach"); + + if CompressionUtils::skip_encoding(&content_type, &context) { + return; + } + } + + // Compression is done when the request accepts brotli or gzip encoding + // and the corresponding feature is enabled + if cfg!(feature = "brotli_compression") && CompressionUtils::accepts_encoding(request, "br") + { + if let Some(plain) = response.take_body() { + let content_type_top = content_type.as_ref().map(|ct| ct.top()); + let mut params = brotli::enc::BrotliEncoderInitParams(); + params.quality = 2; + if content_type_top == Some("text".into()) { + params.mode = BrotliEncoderMode::BROTLI_MODE_TEXT; + } else if content_type_top == Some("font".into()) { + params.mode = BrotliEncoderMode::BROTLI_MODE_FONT; + } + + let compressor = + brotli::CompressorReader::with_params(plain.into_inner(), 4096, ¶ms); + + CompressionUtils::set_body_and_encoding( + response, + compressor, + Encoding::EncodingExt("br".into()), + ); + } + } else if cfg!(feature = "gzip_compression") + && CompressionUtils::accepts_encoding(request, "gzip") + { + if let Some(plain) = response.take_body() { + let compressor = GzEncoder::new(plain.into_inner(), flate2::Compression::default()); + + CompressionUtils::set_body_and_encoding(response, compressor, Encoding::Gzip); + } + } + } +} diff --git a/contrib/lib/src/compression/responder.rs b/contrib/lib/src/compression/responder.rs new file mode 100644 index 00000000..3d8295a8 --- /dev/null +++ b/contrib/lib/src/compression/responder.rs @@ -0,0 +1,56 @@ +//! Response on demand compression. +//! +//! See the [`Compression`](compression::responder::Compressed) type for +//! further details. + +use rocket::response::{self, Responder, Response}; +use rocket::Request; + +crate use super::CompressionUtils; + +/// Compress a `Responder` response ignoring the compression exclusions. +/// +/// Delegates the remainder of the response to the wrapped `Responder`. +/// +/// # Usage +/// +/// To use, add the `brotli_compression` feature, the `gzip_compression` +/// feature, or the `compression` feature (to enable both algorithms) to the +/// `rocket_contrib` dependencies section of your `Cargo.toml`: +/// +/// ```toml,ignore +/// [dependencies.rocket_contrib] +/// version = "*" +/// default-features = false +/// features = ["compression"] +/// ``` +/// +/// Then, compress the desired response wrapping a `Responder` inside +/// `Compressed`: +/// +/// ```rust +/// use rocket_contrib::compression::Compressed; +/// +/// # #[allow(unused_variables)] +/// let response = Compressed("Hi."); +/// ``` +#[derive(Debug)] +pub struct Compressed(pub R); + +impl<'r, R: Responder<'r>> Compressed { + pub fn new(response: R) -> Compressed { + Compressed { 0: response } + } +} + +impl<'r, R: Responder<'r>> Responder<'r> for Compressed { + #[inline(always)] + fn respond_to(self, request: &Request) -> response::Result<'r> { + let mut response = Response::build() + .merge(self.0.respond_to(request)?) + .finalize(); + + CompressionUtils::compress_response(request, &mut response, false); + Ok(response) + } +} diff --git a/contrib/lib/src/lib.rs b/contrib/lib/src/lib.rs index ef17a8e3..0e0bb425 100644 --- a/contrib/lib/src/lib.rs +++ b/contrib/lib/src/lib.rs @@ -54,3 +54,12 @@ #[cfg(feature="databases")] extern crate rocket_contrib_codegen; #[cfg(feature="databases")] #[doc(hidden)] pub use rocket_contrib_codegen::*; + +#[cfg(any(feature="brotli_compression", feature="gzip_compression"))] +pub mod compression; + +#[cfg(feature="brotli_compression")] +extern crate brotli; + +#[cfg(feature="gzip_compression")] +extern crate flate2; diff --git a/contrib/lib/tests/compressed.rs b/contrib/lib/tests/compressed.rs new file mode 100644 index 00000000..376c9edd --- /dev/null +++ b/contrib/lib/tests/compressed.rs @@ -0,0 +1,244 @@ +#![feature(decl_macro, proc_macro_hygiene)] + +#[macro_use] +extern crate rocket; +extern crate rocket_contrib; + +#[cfg(all(feature = "brotli_compression", feature = "gzip_compression"))] +mod compressed_tests { + extern crate brotli; + extern crate flate2; + + use rocket::http::hyper::header::{ContentEncoding, Encoding}; + use rocket::http::Status; + use rocket::http::{ContentType, Header}; + use rocket::local::Client; + use rocket::response::{Content, Response}; + use rocket::routes; + use rocket_contrib::compression::Compressed; + + use std::io::Cursor; + use std::io::Read; + + use self::flate2::read::{GzDecoder, GzEncoder}; + + const HELLO: &str = r"This is a message to hello with more than 100 bytes \ + in order to have to read more than one buffer when gzipping. こんにちは!"; + + #[get("/")] + pub fn index() -> Compressed { + Compressed::new(String::from(HELLO)) + } + + #[get("/font")] + pub fn font() -> Compressed> { + Compressed::new(Content(ContentType::WOFF, HELLO)) + } + + #[get("/image")] + pub fn image() -> Compressed> { + Compressed::new(Content(ContentType::PNG, HELLO)) + } + + #[get("/already_encoded")] + pub fn already_encoded() -> Compressed> { + let mut encoder = GzEncoder::new( + Cursor::new(String::from(HELLO)), + flate2::Compression::default(), + ); + let mut encoded = Vec::new(); + encoder.read_to_end(&mut encoded).unwrap(); + Compressed::new( + Response::build() + .header(ContentEncoding(vec![Encoding::Gzip])) + .sized_body(Cursor::new(encoded)) + .finalize(), + ) + } + + #[get("/identity")] + pub fn identity() -> Compressed> { + Compressed::new( + Response::build() + .header(ContentEncoding(vec![Encoding::Identity])) + .sized_body(Cursor::new(String::from(HELLO))) + .finalize(), + ) + } + + fn rocket() -> rocket::Rocket { + rocket::ignite().mount("/", routes![index, font, image, already_encoded, identity]) + } + + #[test] + fn test_prioritizes_brotli() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/") + .header(Header::new("Accept-Encoding", "deflate, gzip, br")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(response + .headers() + .get("Content-Encoding") + .any(|x| x == "br")); + let mut body_plain = Cursor::new(Vec::::new()); + brotli::BrotliDecompress( + &mut Cursor::new(response.body_bytes().unwrap()), + &mut body_plain, + ) + .expect("decompress response"); + assert_eq!( + String::from_utf8(body_plain.get_mut().to_vec()).unwrap(), + String::from(HELLO) + ); + } + + #[test] + fn test_br_font() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/font") + .header(Header::new("Accept-Encoding", "deflate, gzip, br")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(response + .headers() + .get("Content-Encoding") + .any(|x| x == "br")); + let mut body_plain = Cursor::new(Vec::::new()); + brotli::BrotliDecompress( + &mut Cursor::new(response.body_bytes().unwrap()), + &mut body_plain, + ) + .expect("decompress response"); + assert_eq!( + String::from_utf8(body_plain.get_mut().to_vec()).unwrap(), + String::from(HELLO) + ); + } + + #[test] + fn test_fallback_gzip() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/") + .header(Header::new("Accept-Encoding", "deflate, gzip")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(!response + .headers() + .get("Content-Encoding") + .any(|x| x == "br")); + assert!(response + .headers() + .get("Content-Encoding") + .any(|x| x == "gzip")); + let mut s = String::new(); + GzDecoder::new(&response.body_bytes().unwrap()[..]) + .read_to_string(&mut s) + .expect("decompress response"); + assert_eq!(s, String::from(HELLO)); + } + + #[test] + fn test_does_not_recompress() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/already_encoded") + .header(Header::new("Accept-Encoding", "deflate, gzip, br")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(!response + .headers() + .get("Content-Encoding") + .any(|x| x == "br")); + assert!(response + .headers() + .get("Content-Encoding") + .any(|x| x == "gzip")); + let mut s = String::new(); + GzDecoder::new(&response.body_bytes().unwrap()[..]) + .read_to_string(&mut s) + .expect("decompress response"); + assert_eq!(s, String::from(HELLO)); + } + + #[test] + fn test_does_not_compress_explicit_identity() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/identity") + .header(Header::new("Accept-Encoding", "deflate, gzip, br")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(!response + .headers() + .get("Content-Encoding") + .any(|x| x != "identity")); + assert_eq!( + String::from_utf8(response.body_bytes().unwrap()).unwrap(), + String::from(HELLO) + ); + } + + #[test] + fn test_ignore_exceptions() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/image") + .header(Header::new("Accept-Encoding", "deflate, gzip, br")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(response + .headers() + .get("Content-Encoding") + .any(|x| x == "br")); + let mut body_plain = Cursor::new(Vec::::new()); + brotli::BrotliDecompress( + &mut Cursor::new(response.body_bytes().unwrap()), + &mut body_plain, + ) + .expect("decompress response"); + assert_eq!( + String::from_utf8(body_plain.get_mut().to_vec()).unwrap(), + String::from(HELLO) + ); + } + + #[test] + fn test_ignores_unimplemented_encodings() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/") + .header(Header::new("Accept-Encoding", "deflate")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(!response + .headers() + .get("Content-Encoding") + .any(|x| x != "identity")); + assert_eq!( + String::from_utf8(response.body_bytes().unwrap()).unwrap(), + String::from(HELLO) + ); + } + + #[test] + fn test_respects_identity_only() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/") + .header(Header::new("Accept-Encoding", "identity")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(!response + .headers() + .get("Content-Encoding") + .any(|x| x != "identity")); + assert_eq!( + String::from_utf8(response.body_bytes().unwrap()).unwrap(), + String::from(HELLO) + ); + } +} diff --git a/contrib/lib/tests/compression.rs b/contrib/lib/tests/compression.rs new file mode 100644 index 00000000..eb5f7caa --- /dev/null +++ b/contrib/lib/tests/compression.rs @@ -0,0 +1,307 @@ +#![feature(decl_macro, proc_macro_hygiene)] + +#[macro_use] +extern crate rocket; +extern crate rocket_contrib; + +#[cfg(all(feature = "brotli_compression", feature = "gzip_compression"))] +mod compression_tests { + extern crate brotli; + extern crate flate2; + + use rocket::config::{Config, Environment}; + use rocket::http::hyper::header::{ContentEncoding, Encoding}; + use rocket::http::Status; + use rocket::http::{ContentType, Header}; + use rocket::local::Client; + use rocket::response::Response; + use rocket::routes; + + use std::io::Cursor; + use std::io::Read; + + use self::flate2::read::{GzDecoder, GzEncoder}; + + const HELLO: &str = r"This is a message to hello with more than 100 bytes \ + in order to have to read more than one buffer when gzipping. こんにちは!"; + + #[get("/")] + pub fn index() -> String { + String::from(HELLO) + } + + #[get("/font")] + pub fn font() -> Response<'static> { + Response::build() + .header(ContentType::WOFF) + .sized_body(Cursor::new(String::from(HELLO))) + .finalize() + } + + #[get("/image")] + pub fn image() -> Response<'static> { + Response::build() + .header(ContentType::PNG) + .sized_body(Cursor::new(String::from(HELLO))) + .finalize() + } + + #[get("/tar")] + pub fn tar() -> Response<'static> { + Response::build() + .header(ContentType::TAR) + .sized_body(Cursor::new(String::from(HELLO))) + .finalize() + } + + #[get("/already_encoded")] + pub fn already_encoded() -> Response<'static> { + let mut encoder = GzEncoder::new( + Cursor::new(String::from(HELLO)), + flate2::Compression::default(), + ); + let mut encoded = Vec::new(); + encoder.read_to_end(&mut encoded).unwrap(); + Response::build() + .header(ContentEncoding(vec![Encoding::Gzip])) + .sized_body(Cursor::new(encoded)) + .finalize() + } + + #[get("/identity")] + pub fn identity() -> Response<'static> { + Response::build() + .header(ContentEncoding(vec![Encoding::Identity])) + .sized_body(Cursor::new(String::from(HELLO))) + .finalize() + } + + fn rocket() -> rocket::Rocket { + rocket::ignite() + .mount( + "/", + routes![index, font, image, tar, already_encoded, identity], + ) + .attach(rocket_contrib::compression::Compression::fairing()) + } + + fn rocket_tar_exception() -> rocket::Rocket { + let mut table = std::collections::BTreeMap::new(); + table.insert("exclude".to_string(), vec!["application/x-tar"]); + let config = Config::build(Environment::Development) + .extra("compress", table) + .expect("valid configuration"); + + rocket::custom(config) + .mount("/", routes![image, tar]) + .attach(rocket_contrib::compression::Compression::fairing()) + } + + #[test] + fn test_prioritizes_brotli() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/") + .header(Header::new("Accept-Encoding", "deflate, gzip, br")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(response + .headers() + .get("Content-Encoding") + .any(|x| x == "br")); + let mut body_plain = Cursor::new(Vec::::new()); + brotli::BrotliDecompress( + &mut Cursor::new(response.body_bytes().unwrap()), + &mut body_plain, + ) + .expect("decompress response"); + assert_eq!( + String::from_utf8(body_plain.get_mut().to_vec()).unwrap(), + String::from(HELLO) + ); + } + + #[test] + fn test_br_font() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/font") + .header(Header::new("Accept-Encoding", "deflate, gzip, br")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(response + .headers() + .get("Content-Encoding") + .any(|x| x == "br")); + let mut body_plain = Cursor::new(Vec::::new()); + brotli::BrotliDecompress( + &mut Cursor::new(response.body_bytes().unwrap()), + &mut body_plain, + ) + .expect("decompress response"); + assert_eq!( + String::from_utf8(body_plain.get_mut().to_vec()).unwrap(), + String::from(HELLO) + ); + } + + #[test] + fn test_fallback_gzip() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/") + .header(Header::new("Accept-Encoding", "deflate, gzip")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(!response + .headers() + .get("Content-Encoding") + .any(|x| x == "br")); + assert!(response + .headers() + .get("Content-Encoding") + .any(|x| x == "gzip")); + let mut s = String::new(); + GzDecoder::new(&response.body_bytes().unwrap()[..]) + .read_to_string(&mut s) + .expect("decompress response"); + assert_eq!(s, String::from(HELLO)); + } + + #[test] + fn test_does_not_recompress() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/already_encoded") + .header(Header::new("Accept-Encoding", "deflate, gzip, br")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(!response + .headers() + .get("Content-Encoding") + .any(|x| x == "br")); + assert!(response + .headers() + .get("Content-Encoding") + .any(|x| x == "gzip")); + let mut s = String::new(); + GzDecoder::new(&response.body_bytes().unwrap()[..]) + .read_to_string(&mut s) + .expect("decompress response"); + assert_eq!(s, String::from(HELLO)); + } + + #[test] + fn test_does_not_compress_explicit_identity() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/identity") + .header(Header::new("Accept-Encoding", "deflate, gzip, br")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(!response + .headers() + .get("Content-Encoding") + .any(|x| x != "identity")); + assert_eq!( + String::from_utf8(response.body_bytes().unwrap()).unwrap(), + String::from(HELLO) + ); + } + + #[test] + fn test_does_not_compress_image() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/image") + .header(Header::new("Accept-Encoding", "deflate, gzip, br")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(!response + .headers() + .get("Content-Encoding") + .any(|x| x != "identity")); + assert_eq!( + String::from_utf8(response.body_bytes().unwrap()).unwrap(), + String::from(HELLO) + ); + } + + #[test] + fn test_ignores_unimplemented_encodings() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/") + .header(Header::new("Accept-Encoding", "deflate")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(!response + .headers() + .get("Content-Encoding") + .any(|x| x != "identity")); + assert_eq!( + String::from_utf8(response.body_bytes().unwrap()).unwrap(), + String::from(HELLO) + ); + } + + #[test] + fn test_respects_identity_only() { + let client = Client::new(rocket()).expect("valid rocket instance"); + let mut response = client + .get("/") + .header(Header::new("Accept-Encoding", "identity")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(!response + .headers() + .get("Content-Encoding") + .any(|x| x != "identity")); + assert_eq!( + String::from_utf8(response.body_bytes().unwrap()).unwrap(), + String::from(HELLO) + ); + } + + #[test] + fn test_does_not_compress_custom_exception() { + let client = Client::new(rocket_tar_exception()).expect("valid rocket instance"); + let mut response = client + .get("/tar") + .header(Header::new("Accept-Encoding", "deflate, gzip, br")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(!response + .headers() + .get("Content-Encoding") + .any(|x| x != "identity")); + assert_eq!( + String::from_utf8(response.body_bytes().unwrap()).unwrap(), + String::from(HELLO) + ); + } + + #[test] + fn test_compress_custom_removed_exception() { + let client = Client::new(rocket_tar_exception()).expect("valid rocket instance"); + let mut response = client + .get("/image") + .header(Header::new("Accept-Encoding", "deflate, gzip, br")) + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert!(response + .headers() + .get("Content-Encoding") + .any(|x| x == "br")); + let mut body_plain = Cursor::new(Vec::::new()); + brotli::BrotliDecompress( + &mut Cursor::new(response.body_bytes().unwrap()), + &mut body_plain, + ) + .expect("decompress response"); + assert_eq!( + String::from_utf8(body_plain.get_mut().to_vec()).unwrap(), + String::from(HELLO) + ); + } +}