Skip to main content

web_static_pack/
content_encoding.rs

1//! Content encoding negotiation and content resolver types.
2
3use crate::file::File;
4use anyhow::{Error, bail};
5use http::{HeaderMap, HeaderValue, header};
6use std::cell::Cell;
7
8/// Describes accepted content encodings.
9///
10/// Should be created by parsing `accept-encoding` header, through one of
11/// `from_` methods.
12///
13/// `identity` is always considered to be accepted.
14#[derive(PartialEq, Eq, Debug)]
15pub struct EncodingAccepted {
16    /// Whether `gzip` encoding is accepted.
17    pub gzip: bool,
18    /// Whether `brotli` encoding is accepted.
19    pub brotli: bool,
20}
21impl EncodingAccepted {
22    /// Constructs [self] with none encoding (except for always available
23    /// identity) enabled.
24    pub fn none() -> Self {
25        Self {
26            gzip: false,
27            brotli: false,
28        }
29    }
30
31    /// Constructs [self] from [HeaderMap]. Inside it looks only for
32    /// `accept-encoding` header. May return error if header contains
33    /// invalid string.
34    pub fn from_headers(headers: &HeaderMap) -> Result<Self, Error> {
35        let accept_encoding = match headers.get(header::ACCEPT_ENCODING) {
36            Some(accept_encoding) => accept_encoding,
37            None => return Ok(Self::none()),
38        };
39
40        let self_ = Self::from_accept_encoding_header_raw(accept_encoding)?;
41
42        Ok(self_)
43    }
44    /// Constructs [self] from [HeaderValue] for `accept-encoding` header. May
45    /// return error if header contains invalid string.
46    pub fn from_accept_encoding_header_raw(accept_encoding: &HeaderValue) -> Result<Self, Error> {
47        let accept_encoding = match accept_encoding.to_str() {
48            Ok(accept_encoding) => accept_encoding,
49            Err(_) => bail!("unable to parse accept encoding as string"),
50        };
51
52        let self_ = Self::from_accept_encoding_header_str(accept_encoding);
53
54        Ok(self_)
55    }
56    /// Constructs [self] from `accept-encoding` header value.
57    pub fn from_accept_encoding_header_str(accept_encoding: &str) -> Self {
58        let mut gzip = false;
59        let mut brotli = false;
60
61        // Some servers use `,` as separator, some use `, `, some use both. So we split
62        // by `,` and trim each value.
63        accept_encoding
64            .split(",")
65            .map(|accept_encoding| accept_encoding.trim())
66            .map(Self::extract_algorithm_from_value)
67            .for_each(|accept_encoding| match accept_encoding {
68                "gzip" => {
69                    gzip = true;
70                }
71                "br" => {
72                    brotli = true;
73                }
74                _ => {}
75            });
76
77        Self { gzip, brotli }
78    }
79
80    /// Removes `quality` or `preference` from header value.
81    /// eg. changes `gzip;q=0.5` to `gzip`
82    pub fn extract_algorithm_from_value(mut value: &str) -> &str {
83        if let Some((algorithm, _)) = value.split_once(";q=") {
84            value = algorithm;
85        }
86        value
87    }
88}
89
90#[cfg(test)]
91mod test_encoding_accepted {
92    use super::EncodingAccepted;
93    use http::{HeaderMap, HeaderName, HeaderValue};
94    use test_case::test_case;
95
96    #[test_case(&[], Some(EncodingAccepted::none()))]
97    #[test_case(&[("accept-encoding", "gzip")], Some(EncodingAccepted { gzip: true, brotli: false }))]
98    fn from_headers_returns_expected(
99        headers: &[(&'static str, &'static str)],
100        expected: Option<EncodingAccepted>,
101    ) {
102        let headers_map = headers
103            .iter()
104            .copied()
105            .map(|(key, value)| {
106                (
107                    HeaderName::from_static(key),
108                    HeaderValue::from_static(value),
109                )
110            })
111            .collect::<HeaderMap>();
112
113        assert_eq!(EncodingAccepted::from_headers(&headers_map).ok(), expected);
114    }
115
116    #[test_case(HeaderValue::from_bytes(b"\xff").unwrap(), None)]
117    #[test_case(HeaderValue::from_static(""), Some(EncodingAccepted { gzip: false, brotli: false }))]
118    #[test_case(HeaderValue::from_static("gzip, compress, br"), Some(EncodingAccepted { gzip: true, brotli: true }))]
119    fn from_accept_encoding_header_raw_returns_expected(
120        header_value: HeaderValue,
121        expected: Option<EncodingAccepted>,
122    ) {
123        assert_eq!(
124            EncodingAccepted::from_accept_encoding_header_raw(&header_value).ok(),
125            expected
126        );
127    }
128
129    #[test_case("", EncodingAccepted { gzip: false, brotli: false })]
130    #[test_case("gzip", EncodingAccepted { gzip: true, brotli: false })]
131    #[test_case("br", EncodingAccepted { gzip: false, brotli: true })]
132    #[test_case("deflate, gzip;q=1.0", EncodingAccepted { gzip: true, brotli: false })]
133    #[test_case("br,gzip", EncodingAccepted { gzip: true, brotli: true })]
134    fn from_accept_encoding_header_str_returns_expected(
135        accept_encoding: &str,
136        expected: EncodingAccepted,
137    ) {
138        assert_eq!(
139            EncodingAccepted::from_accept_encoding_header_str(accept_encoding),
140            expected
141        );
142    }
143
144    #[test_case("", "")]
145    #[test_case("gzip", "gzip")]
146    #[test_case("gzip;q=1.0", "gzip")]
147    fn extract_algorithm_from_value_returns_expected(
148        value: &str,
149        expected: &str,
150    ) {
151        assert_eq!(
152            EncodingAccepted::extract_algorithm_from_value(value),
153            expected
154        );
155    }
156}
157
158/// Represents content in resolved content encoding. This should be created by
159/// calling [Self::resolve], providing [EncodingAccepted] from request header
160/// and [File].
161#[derive(PartialEq, Eq, Debug)]
162pub struct ContentContentEncoding<'c> {
163    /// content (body) that should be sent in response
164    pub content: &'c [u8],
165    /// `content-encoding` header value that should be sent in response
166    pub content_encoding: HeaderValue,
167}
168impl<'c> ContentContentEncoding<'c> {
169    /// Based on accepted encodings from [EncodingAccepted] and available from
170    /// [File] resolves best (currently *smallest*) content.
171    pub fn resolve(
172        encoding_accepted: &EncodingAccepted,
173        file: &'c impl File,
174    ) -> Self {
175        let mut best = Cell::new(ContentContentEncoding {
176            content: file.content(),
177            content_encoding: HeaderValue::from_static("identity"),
178        });
179
180        // gzip
181        if encoding_accepted.gzip
182            && let Some(content_gzip) = file.content_gzip()
183            && content_gzip.len() <= best.get_mut().content.len()
184        {
185            best.set(ContentContentEncoding {
186                content: content_gzip,
187                content_encoding: HeaderValue::from_static("gzip"),
188            });
189        }
190
191        // brotli
192        if encoding_accepted.brotli
193            && let Some(content_brotli) = file.content_brotli()
194            && content_brotli.len() <= best.get_mut().content.len()
195        {
196            best.set(ContentContentEncoding {
197                content: content_brotli,
198                content_encoding: HeaderValue::from_static("br"),
199            });
200        }
201
202        best.into_inner()
203    }
204}
205
206#[cfg(test)]
207mod test_content_content_encoding {
208    use super::{ContentContentEncoding, EncodingAccepted};
209    use crate::{cache_control::CacheControl, file::File};
210    use http::HeaderValue;
211    use test_case::test_case;
212
213    #[derive(Debug)]
214    pub struct FileMock {
215        pub content: &'static [u8],
216        pub content_gzip: Option<&'static [u8]>,
217        pub content_brotli: Option<&'static [u8]>,
218    }
219    impl File for FileMock {
220        fn content(&self) -> &[u8] {
221            self.content
222        }
223        fn content_gzip(&self) -> Option<&[u8]> {
224            self.content_gzip
225        }
226        fn content_brotli(&self) -> Option<&[u8]> {
227            self.content_brotli
228        }
229
230        fn content_type(&self) -> HeaderValue {
231            unimplemented!()
232        }
233
234        fn etag(&self) -> HeaderValue {
235            unimplemented!()
236        }
237
238        fn cache_control(&self) -> CacheControl {
239            unimplemented!()
240        }
241    }
242
243    #[test_case(
244        EncodingAccepted { gzip: false, brotli: false },
245        FileMock { content: b"content-identity", content_gzip: None, content_brotli: None },
246        ContentContentEncoding {content: b"content-identity", content_encoding: HeaderValue::from_static("identity") } ;
247        "nothing provided, nothing accepted"
248    )]
249    #[test_case(
250        EncodingAccepted { gzip: false, brotli: false },
251        FileMock { content: b"content-identity", content_gzip: Some(b"content-gzip"), content_brotli: Some(b"content-brotli") },
252        ContentContentEncoding {content: b"content-identity", content_encoding: HeaderValue::from_static("identity") } ;
253        "all provided, nothing accepted"
254    )]
255    #[test_case(
256        EncodingAccepted { gzip: true, brotli: true },
257        FileMock { content: b"content-identity", content_gzip: None, content_brotli: None },
258        ContentContentEncoding {content: b"content-identity", content_encoding: HeaderValue::from_static("identity") } ;
259        "all accepted, nothing provided"
260    )]
261    #[test_case(
262        EncodingAccepted { gzip: true, brotli: true },
263        FileMock { content: b"content-aaa", content_gzip: Some(b"content-bb"), content_brotli: Some(b"content-c") },
264        ContentContentEncoding {content: b"content-c", content_encoding: HeaderValue::from_static("br") } ;
265        "brotli should win as the shortest"
266    )]
267    fn resolve_returns_expected(
268        encoding_accepted: EncodingAccepted,
269        content: FileMock,
270        expected: ContentContentEncoding,
271    ) {
272        assert_eq!(
273            ContentContentEncoding::resolve(&encoding_accepted, &content),
274            expected
275        );
276    }
277}