Skip to main content

topcoat_router/
compression.rs

1use std::{convert::Infallible, future::ready};
2
3use http::{HeaderMap, header::ACCEPT_ENCODING};
4use tower::{ServiceExt, service_fn};
5use tower_http::compression::predicate::{NotForContentType, Predicate, SizeAbove};
6
7use crate::{Body, response::Response};
8
9/// Configures the compression a [`Router`](crate::Router) applies to
10/// responses.
11///
12/// The router compresses each response body with the algorithm negotiated
13/// from the request's `Accept-Encoding` header, after every layer has run.
14/// Compression is enabled by default; pass a configuration to
15/// [`RouterBuilder::compression`](crate::RouterBuilder::compression) to tune
16/// or disable it.
17///
18/// Responses remain unchanged when the client accepts no enabled algorithm,
19/// the body is below [`min_size`](Self::min_size), or its headers indicate
20/// that it should not be compressed. This includes already encoded responses,
21/// range responses, and content types unsuitable for compression.
22///
23/// # Examples
24///
25/// ```rust
26/// use topcoat::router::{Compression, CompressionLevel, Router};
27///
28/// // Disable compression, e.g. when a reverse proxy compresses instead.
29/// let router = Router::builder().compression(Compression::off()).build();
30///
31/// // Trade compression ratio for speed, with gzip only.
32/// let router = Router::builder()
33///     .compression(
34///         Compression::new()
35///             .brotli(false)
36///             .level(CompressionLevel::Fastest),
37///     )
38///     .build();
39/// ```
40#[derive(Clone, Debug)]
41pub struct Compression {
42    /// Whether gzip is offered during negotiation.
43    gzip: bool,
44    /// Whether brotli is offered during negotiation.
45    brotli: bool,
46    /// The compression quality every algorithm encodes with.
47    level: CompressionLevel,
48    /// The body size below which responses are not compressed.
49    min_size: u64,
50}
51
52impl Compression {
53    /// Creates the default configuration: gzip and brotli enabled at the
54    /// [`Balanced`](CompressionLevel::Balanced) level, skipping bodies
55    /// smaller than 32 bytes.
56    #[must_use]
57    pub fn new() -> Self {
58        /// Below this size the compressed framing tends to outweigh the
59        /// savings.
60        const DEFAULT_MIN_SIZE: u64 = 32;
61
62        Self {
63            gzip: true,
64            brotli: true,
65            level: CompressionLevel::default(),
66            min_size: DEFAULT_MIN_SIZE,
67        }
68    }
69
70    /// Creates a configuration with every algorithm disabled, so responses
71    /// are never compressed.
72    ///
73    /// Use this when something in front of the application compresses
74    /// already, like a reverse proxy or CDN.
75    #[must_use]
76    pub fn off() -> Self {
77        Self {
78            gzip: false,
79            brotli: false,
80            ..Self::new()
81        }
82    }
83
84    /// Sets whether gzip is offered during negotiation.
85    #[must_use]
86    pub fn gzip(mut self, enabled: bool) -> Self {
87        self.gzip = enabled;
88        self
89    }
90
91    /// Sets whether brotli is offered during negotiation.
92    #[must_use]
93    pub fn brotli(mut self, enabled: bool) -> Self {
94        self.brotli = enabled;
95        self
96    }
97
98    /// Sets the compression quality every algorithm encodes with.
99    #[must_use]
100    pub fn level(mut self, level: CompressionLevel) -> Self {
101        self.level = level;
102        self
103    }
104
105    /// Sets the body size in bytes below which responses are not compressed.
106    ///
107    /// The limit applies to bodies whose size is known up front (via
108    /// `Content-Length` or an exact size hint); a streaming body of unknown
109    /// size is always compressed.
110    #[must_use]
111    pub fn min_size(mut self, bytes: u64) -> Self {
112        self.min_size = bytes;
113        self
114    }
115
116    /// Compresses `response` with the algorithm negotiated from the request's
117    /// `Accept-Encoding` values, leaving it unchanged when no enabled
118    /// algorithm is accepted or the response should not be compressed.
119    pub(crate) async fn compress(
120        &self,
121        request_headers: &HeaderMap,
122        response: Response,
123    ) -> Response {
124        if !self.gzip && !self.brotli {
125            return response;
126        }
127
128        // Only the `Accept-Encoding` values matter to the negotiation, so the
129        // request handed to the middleware carries just those.
130        let mut request = http::Request::new(());
131        for value in request_headers.get_all(ACCEPT_ENCODING) {
132            request.headers_mut().append(ACCEPT_ENCODING, value.clone());
133        }
134
135        // The same skip conditions as tower-http's default, with the size
136        // threshold configurable.
137        let predicate = SizeAbove::new(self.min_size)
138            .and(NotForContentType::GRPC)
139            .and(NotForContentType::IMAGES)
140            .and(NotForContentType::SSE);
141
142        // The response is already computed; the middleware wraps a one-shot
143        // inner service that just yields it.
144        let mut response = Some(response);
145        let inner = service_fn(move |_: http::Request<()>| {
146            let response = response.take().expect("one-shot service called once");
147            ready(Ok::<_, Infallible>(response))
148        });
149        let service = tower_http::compression::Compression::new(inner)
150            .gzip(self.gzip)
151            .br(self.brotli)
152            .quality(self.level.into_tower())
153            .compress_when(predicate);
154
155        match service.oneshot(request).await {
156            Ok(response) => response.map(Body::new),
157            Err(never) => match never {},
158        }
159    }
160}
161
162impl Default for Compression {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168/// The quality a [`Compression`] configuration encodes with, trading
169/// compression ratio against CPU time.
170#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
171pub enum CompressionLevel {
172    /// The fastest quality, usually producing the biggest output.
173    Fastest,
174    /// A quality suited to compressing responses as they are served.
175    ///
176    /// Balances output size against the CPU time needed for each response.
177    #[default]
178    Balanced,
179    /// The best quality, usually producing the smallest output. With brotli
180    /// this is expensive; prefer it for payloads compressed once and cached.
181    Best,
182    /// A numeric quality interpreted by each algorithm, clamped to the
183    /// algorithm's maximum.
184    Precise(i32),
185}
186
187impl CompressionLevel {
188    /// Maps the level onto the middleware's equivalent.
189    fn into_tower(self) -> tower_http::CompressionLevel {
190        /// The encoding quality used for [`CompressionLevel::Balanced`].
191        const BALANCED_QUALITY: i32 = 4;
192
193        match self {
194            Self::Fastest => tower_http::CompressionLevel::Fastest,
195            Self::Balanced => tower_http::CompressionLevel::Precise(BALANCED_QUALITY),
196            Self::Best => tower_http::CompressionLevel::Best,
197            Self::Precise(quality) => tower_http::CompressionLevel::Precise(quality),
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use std::{borrow::Cow, future::Future};
205
206    use http::{
207        HeaderValue,
208        header::{CONTENT_ENCODING, VARY},
209    };
210    use topcoat_core::context::Cx;
211
212    use super::*;
213    use crate::{
214        HeaderMap, Method, Path, RouteFn, RouteFuture, RouteHandlerFn, Router, request::Bytes,
215        response::IntoResponse, to_bytes,
216    };
217
218    // -- Test helpers --
219
220    fn block_on<F: Future>(future: F) -> F::Output {
221        tokio::runtime::Builder::new_current_thread()
222            .build()
223            .unwrap()
224            .block_on(future)
225    }
226
227    /// Builds a router with `handler` under `GET /x` and the given
228    /// compression configuration.
229    fn router_with(handler: RouteHandlerFn, compression: Compression) -> Router {
230        Router::builder()
231            .route(RouteFn::new(
232                Method::GET,
233                Cow::Borrowed(Path::new("/x")),
234                handler,
235            ))
236            .compression(compression)
237            .build()
238    }
239
240    /// Dispatches a GET request for `/x`, optionally with an
241    /// `Accept-Encoding` header, and reads the full response.
242    fn send(router: &Router, accept_encoding: Option<&str>) -> (HeaderMap, Bytes) {
243        let mut request = http::Request::builder().uri("/x");
244        if let Some(value) = accept_encoding {
245            request = request.header(ACCEPT_ENCODING, value);
246        }
247        let response = block_on(router.handle(request.body(Body::empty()).unwrap()));
248        let (parts, body) = response.into_parts();
249        let bytes = block_on(to_bytes(body, usize::MAX)).unwrap();
250        (parts.headers, bytes)
251    }
252
253    /// A body long enough to clear the default compression size threshold.
254    fn long_body() -> String {
255        "route ".repeat(64)
256    }
257
258    fn long_route(cx: &Cx, _body: Body) -> RouteFuture<'_> {
259        Box::pin(async move { long_body().into_response(cx) })
260    }
261
262    /// A route whose body stays below the default size threshold.
263    fn short_route(cx: &Cx, _body: Body) -> RouteFuture<'_> {
264        Box::pin(async move { "route".into_response(cx) })
265    }
266
267    /// A route that marks its (uncompressed) response as already encoded.
268    fn encoded_route(cx: &Cx, _body: Body) -> RouteFuture<'_> {
269        Box::pin(async move {
270            let mut response = long_body().into_response(cx)?;
271            response
272                .headers_mut()
273                .insert(CONTENT_ENCODING, HeaderValue::from_static("br"));
274            Ok(response)
275        })
276    }
277
278    // -- Compression through the router --
279
280    #[test]
281    fn compresses_with_the_negotiated_algorithm() {
282        let router = router_with(long_route, Compression::new());
283
284        let (headers, body) = send(&router, Some("gzip"));
285        assert_eq!(headers.get(CONTENT_ENCODING).unwrap(), "gzip");
286        assert_eq!(headers.get(VARY).unwrap(), "accept-encoding");
287        assert!(!body.is_empty());
288        assert!(body.len() < long_body().len());
289
290        let (headers, _) = send(&router, Some("br"));
291        assert_eq!(headers.get(CONTENT_ENCODING).unwrap(), "br");
292    }
293
294    #[test]
295    fn passes_through_without_accept_encoding() {
296        let router = router_with(long_route, Compression::new());
297        let (headers, body) = send(&router, None);
298        assert!(!headers.contains_key(CONTENT_ENCODING));
299        assert_eq!(body, long_body());
300    }
301
302    #[test]
303    fn off_disables_compression() {
304        let router = router_with(long_route, Compression::off());
305        let (headers, body) = send(&router, Some("gzip, br"));
306        assert!(!headers.contains_key(CONTENT_ENCODING));
307        assert_eq!(body, long_body());
308    }
309
310    #[test]
311    fn disabled_algorithms_are_not_offered() {
312        let router = router_with(long_route, Compression::new().gzip(false));
313
314        // The client only accepts the disabled algorithm.
315        let (headers, body) = send(&router, Some("gzip"));
316        assert!(!headers.contains_key(CONTENT_ENCODING));
317        assert_eq!(body, long_body());
318
319        // The other algorithm is still negotiated.
320        let (headers, _) = send(&router, Some("gzip, br"));
321        assert_eq!(headers.get(CONTENT_ENCODING).unwrap(), "br");
322    }
323
324    #[test]
325    fn small_bodies_are_not_compressed() {
326        let router = router_with(short_route, Compression::new());
327        let (headers, body) = send(&router, Some("gzip"));
328        assert!(!headers.contains_key(CONTENT_ENCODING));
329        assert_eq!(&body[..], b"route");
330    }
331
332    #[test]
333    fn min_size_lowers_the_compression_threshold() {
334        let router = router_with(short_route, Compression::new().min_size(0));
335        let (headers, _) = send(&router, Some("gzip"));
336        assert_eq!(headers.get(CONTENT_ENCODING).unwrap(), "gzip");
337    }
338
339    #[test]
340    fn already_encoded_responses_pass_through() {
341        let router = router_with(encoded_route, Compression::new());
342        let (headers, body) = send(&router, Some("gzip"));
343        // The route's own encoding wins; the body is not compressed again.
344        assert_eq!(headers.get(CONTENT_ENCODING).unwrap(), "br");
345        assert_eq!(body, long_body());
346    }
347}