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        for accept_encoding in accept_encoding.split(", ") {
62            let accept_encoding = Self::extract_algorithm_from_value(accept_encoding);
63
64            match accept_encoding {
65                "gzip" => {
66                    gzip = true;
67                }
68                "br" => {
69                    brotli = true;
70                }
71                _ => {}
72            }
73        }
74
75        Self { gzip, brotli }
76    }
77
78    /// Removes `quality` or `preference` from header value.
79    /// eg. changes `gzip;q=0.5` to `gzip`
80    pub fn extract_algorithm_from_value(mut value: &str) -> &str {
81        if let Some((algorithm, _)) = value.split_once(";q=") {
82            value = algorithm;
83        }
84        value
85    }
86}
87
88#[cfg(test)]
89mod test_encoding_accepted {
90    use super::EncodingAccepted;
91    use http::{HeaderMap, HeaderName, HeaderValue};
92    use test_case::test_case;
93
94    #[test_case(&[], Some(EncodingAccepted::none()))]
95    #[test_case(&[("accept-encoding", "gzip")], Some(EncodingAccepted { gzip: true, brotli: false }))]
96    fn from_headers_returns_expected(
97        headers: &[(&'static str, &'static str)],
98        expected: Option<EncodingAccepted>,
99    ) {
100        let headers_map = headers
101            .iter()
102            .copied()
103            .map(|(key, value)| {
104                (
105                    HeaderName::from_static(key),
106                    HeaderValue::from_static(value),
107                )
108            })
109            .collect::<HeaderMap>();
110
111        assert_eq!(EncodingAccepted::from_headers(&headers_map).ok(), expected);
112    }
113
114    #[test_case(HeaderValue::from_bytes(b"\xff").unwrap(), None)]
115    #[test_case(HeaderValue::from_static(""), Some(EncodingAccepted { gzip: false, brotli: false }))]
116    #[test_case(HeaderValue::from_static("gzip, compress, br"), Some(EncodingAccepted { gzip: true, brotli: true }))]
117    fn from_accept_encoding_header_raw_returns_expected(
118        header_value: HeaderValue,
119        expected: Option<EncodingAccepted>,
120    ) {
121        assert_eq!(
122            EncodingAccepted::from_accept_encoding_header_raw(&header_value).ok(),
123            expected
124        );
125    }
126
127    #[test_case("", EncodingAccepted { gzip: false, brotli: false })]
128    #[test_case("gzip", EncodingAccepted { gzip: true, brotli: false })]
129    #[test_case("br", EncodingAccepted { gzip: false, brotli: true })]
130    #[test_case("deflate, gzip;q=1.0", EncodingAccepted { gzip: true, brotli: false })]
131    fn from_accept_encoding_header_str_returns_expected(
132        accept_encoding: &str,
133        expected: EncodingAccepted,
134    ) {
135        assert_eq!(
136            EncodingAccepted::from_accept_encoding_header_str(accept_encoding),
137            expected
138        );
139    }
140
141    #[test_case("", "")]
142    #[test_case("gzip", "gzip")]
143    #[test_case("gzip;q=1.0", "gzip")]
144    fn extract_algorithm_from_value_returns_expected(
145        value: &str,
146        expected: &str,
147    ) {
148        assert_eq!(
149            EncodingAccepted::extract_algorithm_from_value(value),
150            expected
151        );
152    }
153}
154
155/// Represents content in resolved content encoding. This should be created by
156/// calling [Self::resolve], providing [EncodingAccepted] from request header
157/// and [File].
158#[derive(PartialEq, Eq, Debug)]
159pub struct ContentContentEncoding<'c> {
160    /// content (body) that should be sent in response
161    pub content: &'c [u8],
162    /// `content-encoding` header value that should be sent in response
163    pub content_encoding: HeaderValue,
164}
165impl<'c> ContentContentEncoding<'c> {
166    /// Based on accepted encodings from [EncodingAccepted] and available from
167    /// [File] resolves best (currently *smallest*) content.
168    pub fn resolve(
169        encoding_accepted: &EncodingAccepted,
170        file: &'c impl File,
171    ) -> Self {
172        let mut best = Cell::new(ContentContentEncoding {
173            content: file.content(),
174            content_encoding: HeaderValue::from_static("identity"),
175        });
176
177        // gzip
178        if encoding_accepted.gzip
179            && let Some(content_gzip) = file.content_gzip()
180            && content_gzip.len() <= best.get_mut().content.len()
181        {
182            best.set(ContentContentEncoding {
183                content: content_gzip,
184                content_encoding: HeaderValue::from_static("gzip"),
185            });
186        }
187
188        // brotli
189        if encoding_accepted.brotli
190            && let Some(content_brotli) = file.content_brotli()
191            && content_brotli.len() <= best.get_mut().content.len()
192        {
193            best.set(ContentContentEncoding {
194                content: content_brotli,
195                content_encoding: HeaderValue::from_static("br"),
196            });
197        }
198
199        best.into_inner()
200    }
201}
202
203#[cfg(test)]
204mod test_content_content_encoding {
205    use super::{ContentContentEncoding, EncodingAccepted};
206    use crate::{cache_control::CacheControl, file::File};
207    use http::HeaderValue;
208    use test_case::test_case;
209
210    #[derive(Debug)]
211    pub struct FileMock {
212        pub content: &'static [u8],
213        pub content_gzip: Option<&'static [u8]>,
214        pub content_brotli: Option<&'static [u8]>,
215    }
216    impl File for FileMock {
217        fn content(&self) -> &[u8] {
218            self.content
219        }
220        fn content_gzip(&self) -> Option<&[u8]> {
221            self.content_gzip
222        }
223        fn content_brotli(&self) -> Option<&[u8]> {
224            self.content_brotli
225        }
226
227        fn content_type(&self) -> HeaderValue {
228            unimplemented!()
229        }
230
231        fn etag(&self) -> HeaderValue {
232            unimplemented!()
233        }
234
235        fn cache_control(&self) -> CacheControl {
236            unimplemented!()
237        }
238    }
239
240    #[test_case(
241        EncodingAccepted { gzip: false, brotli: false },
242        FileMock { content: b"content-identity", content_gzip: None, content_brotli: None },
243        ContentContentEncoding {content: b"content-identity", content_encoding: HeaderValue::from_static("identity") } ;
244        "nothing provided, nothing accepted"
245    )]
246    #[test_case(
247        EncodingAccepted { gzip: false, brotli: false },
248        FileMock { content: b"content-identity", content_gzip: Some(b"content-gzip"), content_brotli: Some(b"content-brotli") },
249        ContentContentEncoding {content: b"content-identity", content_encoding: HeaderValue::from_static("identity") } ;
250        "all provided, nothing accepted"
251    )]
252    #[test_case(
253        EncodingAccepted { gzip: true, brotli: true },
254        FileMock { content: b"content-identity", content_gzip: None, content_brotli: None },
255        ContentContentEncoding {content: b"content-identity", content_encoding: HeaderValue::from_static("identity") } ;
256        "all accepted, nothing provided"
257    )]
258    #[test_case(
259        EncodingAccepted { gzip: true, brotli: true },
260        FileMock { content: b"content-aaa", content_gzip: Some(b"content-bb"), content_brotli: Some(b"content-c") },
261        ContentContentEncoding {content: b"content-c", content_encoding: HeaderValue::from_static("br") } ;
262        "brotli should win as the shortest"
263    )]
264    fn resolve_returns_expected(
265        encoding_accepted: EncodingAccepted,
266        content: FileMock,
267        expected: ContentContentEncoding,
268    ) {
269        assert_eq!(
270            ContentContentEncoding::resolve(&encoding_accepted, &content),
271            expected
272        );
273    }
274}