web_faith_encoding/lib.rs
1//! HTTP content coding for request and response bodies.
2//!
3//! Currently supports:
4//!
5//! - gzip ([RFC 1952](https://www.rfc-editor.org/rfc/rfc1952))
6//! - deflate, in its zlib-wrapped form ([RFC 1950](https://www.rfc-editor.org/rfc/rfc1950)), like
7//! browsers
8//! - brotli ([RFC 7932](https://www.rfc-editor.org/rfc/rfc7932))
9//! - zstd ([RFC 8878](https://www.rfc-editor.org/rfc/rfc8878))
10//!
11//! # Requests
12//!
13//! - Use [`encode`] or [`encode_stream`] to compress a request body and declare it, together.
14//!
15//! ```
16//! use http::HeaderMap;
17//! use web_faith_encoding::{Coding, request::encode};
18//!
19//! # async fn example() {
20//! let mut headers = HeaderMap::new();
21//! let body = b"the quick brown fox".repeat(8);
22//!
23//! let compressed = encode(&mut headers, &body, Coding::Gzip).await.expect("gzip compresses");
24//! assert!(compressed.len() < body.len());
25//! assert_eq!(headers["content-encoding"], "gzip");
26//! # }
27//! ```
28//!
29//! To drive the halves separately, [`compress_buffer`] and [`compress_stream`] do the body, and
30//! [`ContentEncoding::layer`] with [`to_header_value`](ContentEncoding::to_header_value) does the
31//! header.
32//!
33//! # Responses
34//!
35//! - Use [`AcceptEncoding`] to parse the advertised supported coding set from the request.
36//! - Use [`decode`] to take one layer off a response: it decodes the body and updates the headers
37//! together.
38//! - A body encoded more than once takes one call per layer.
39//!
40//! ```
41//! use std::pin::Pin;
42//!
43//! use bytes::Bytes;
44//! use futures::TryStreamExt as _;
45//! use http::{HeaderMap, HeaderValue};
46//! use web_faith_encoding::{
47//! Coding,
48//! request::encode,
49//! response::{AcceptEncoding, ByteStream, decode},
50//! };
51//!
52//! # async fn example() {
53//! let mut request = HeaderMap::new();
54//! request.insert("accept-encoding", HeaderValue::from_static("gzip, br;q=0.5"));
55//! let accept = AcceptEncoding::from(&request);
56//!
57//! // A body gzipped over a coding the caller applied themselves.
58//! let mut response = HeaderMap::new();
59//! response.insert("content-encoding", HeaderValue::from_static("custom-thing"));
60//! let gzipped = encode(&mut response, b"pretend this is custom-thing", Coding::Gzip)
61//! .await
62//! .expect("gzip compresses");
63//! assert_eq!(response["content-encoding"], "custom-thing, gzip");
64//!
65//! let body: Pin<Box<ByteStream>> =
66//! Box::pin(futures::stream::once(async move { Ok(Bytes::from(gzipped)) }));
67//! let decoded: Vec<u8> = decode(&mut response, body, &accept)
68//! .try_fold(Vec::new(), |mut acc, chunk| async move {
69//! acc.extend_from_slice(&chunk);
70//! Ok(acc)
71//! })
72//! .await
73//! .expect("the gzip decodes");
74//!
75//! // gzip came off, and the header names what is still under it.
76//! assert_eq!(decoded, b"pretend this is custom-thing");
77//! assert_eq!(response["content-encoding"], "custom-thing");
78//! # }
79//! ```
80//!
81//! [`encode`]: request::encode
82//! [`encode_stream`]: request::encode_stream
83//! [`compress_buffer`]: request::compress_buffer
84//! [`compress_stream`]: request::compress_stream
85//! [`AcceptEncoding`]: response::AcceptEncoding
86//! [`decode`]: response::decode
87//! [`decode_stream`]: response::decode_stream
88
89#![deny(missing_docs)]
90// Lets docs.rs label each item with the feature or platform it needs.
91#![cfg_attr(docsrs, feature(doc_cfg))]
92
93pub mod request;
94pub mod response;
95
96use std::fmt;
97
98use http::header::{CONTENT_ENCODING, CONTENT_LENGTH, HeaderMap, HeaderValue};
99
100use crate::response::AcceptEncoding;
101
102/// The codings a response says its body carries.
103///
104/// Read from every `Content-Encoding` line together: a representation encoded more than once may
105/// arrive comma-joined on one line or split across several, and it is the same list either way.
106#[derive(Clone, Debug, Default)]
107pub struct ContentEncoding {
108 codings: Vec<Coding>,
109 /// A line that was not valid ASCII, so what the body carries is not knowable.
110 unreadable: bool,
111}
112
113impl From<&str> for ContentEncoding {
114 /// Read one `Content-Encoding` header value.
115 fn from(value: &str) -> Self {
116 let mut this = Self::default();
117 this.merge(value);
118 this
119 }
120}
121
122impl From<&HeaderMap> for ContentEncoding {
123 fn from(headers: &HeaderMap) -> Self {
124 let mut this = Self::default();
125 for value in headers.get_all(CONTENT_ENCODING) {
126 let Ok(value) = value.to_str() else {
127 this.unreadable = true;
128 continue;
129 };
130 this.merge(value);
131 }
132 this
133 }
134}
135
136impl ContentEncoding {
137 /// Fold one header value's codings into what is already here.
138 fn merge(&mut self, value: &str) {
139 self.codings.extend(
140 value
141 .split(',')
142 .map(str::trim)
143 .filter(|token| !token.is_empty())
144 .map(Coding::from_token),
145 );
146 }
147
148 /// The codings the header carried, in the order it applied them.
149 ///
150 /// Empty for a response that declared none, and for one whose header could not be read.
151 pub fn codings(&self) -> &[Coding] {
152 &self.codings
153 }
154
155 /// Add a coding on top of the ones already here.
156 ///
157 /// Applied last, so it is last in the header: the codings are listed in the order they were
158 /// applied, and a reader unwinds them in reverse.
159 pub fn layer(&self, coding: Coding) -> Self {
160 let mut layered = self.clone();
161 layered.codings.push(coding);
162 layered
163 }
164
165 /// The header value these codings make, or `None` when there are none to declare.
166 pub fn to_header_value(&self) -> Option<HeaderValue> {
167 if self.codings.is_empty() {
168 return None;
169 }
170
171 HeaderValue::from_str(&self.to_string()).ok()
172 }
173
174 /// The coding the next layer of the body is under, given what the request accepted.
175 ///
176 /// The last coding, being the last applied and so the first to unwind. `None` leaves the body
177 /// as it arrived, which covers a response that declared no coding, one whose outermost coding
178 /// this crate cannot decode (`identity` among them) or the request did not accept, and one
179 /// whose header was not readable.
180 pub fn can_decode_as(&self, accept: &AcceptEncoding) -> Option<Coding> {
181 if self.unreadable {
182 return None;
183 }
184
185 let outermost = self.codings.last()?;
186 (outermost.is_supported() && accept.accepts(outermost)).then(|| outermost.clone())
187 }
188
189 /// These codings with the outermost removed, as the body stands once it is decoded.
190 pub fn peeled(&self) -> Self {
191 let mut peeled = self.clone();
192 peeled.codings.pop();
193 peeled
194 }
195
196 /// Take one layer off `headers`, returning the coding its body is under.
197 ///
198 /// The headers are left describing the body once that coding has been decoded, which
199 /// [`response::decode`] does in the same call. Reach for this only to
200 /// drive the two halves separately.
201 ///
202 /// `Content-Encoding` keeps whatever layers remain and goes when none do; `Content-Length`
203 /// goes either way, no longer describing what the caller reads. `None` leaves `headers` as
204 /// they are.
205 pub fn peel_one_header(headers: &mut HeaderMap, accept: &AcceptEncoding) -> Option<Coding> {
206 let encoding = Self::from(&*headers);
207 let coding = encoding.can_decode_as(accept)?;
208
209 match encoding.peeled().to_header_value() {
210 Some(value) => headers.insert(CONTENT_ENCODING, value),
211 None => headers.remove(CONTENT_ENCODING),
212 };
213 headers.remove(CONTENT_LENGTH);
214
215 Some(coding)
216 }
217}
218
219impl fmt::Display for ContentEncoding {
220 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221 for (n, coding) in self.codings.iter().enumerate() {
222 if n > 0 {
223 f.write_str(", ")?;
224 }
225 f.write_str(coding.token())?;
226 }
227 Ok(())
228 }
229}
230
231/// A content coding.
232///
233/// The four this crate decodes, and [`Other`](Self::Other) for any token it does not. Marked
234/// non-exhaustive: a coding that becomes standard should not be a breaking change here.
235#[derive(Clone, Debug, PartialEq, Eq)]
236#[non_exhaustive]
237pub enum Coding {
238 /// [RFC 1952](https://www.rfc-editor.org/rfc/rfc1952).
239 Gzip,
240 /// In its zlib-wrapped form ([RFC 1950](https://www.rfc-editor.org/rfc/rfc1950)), like browsers.
241 Deflate,
242 /// [RFC 7932](https://www.rfc-editor.org/rfc/rfc7932).
243 Brotli,
244 /// [RFC 8878](https://www.rfc-editor.org/rfc/rfc8878).
245 Zstd,
246 /// A coding named on the wire that this crate does not decode, lowercased.
247 ///
248 /// Includes `identity`, which means the absence of a coding rather than one to apply.
249 Other(String),
250}
251
252impl Coding {
253 /// Match the `compress` option's value, given as a coding's wire token.
254 ///
255 /// Matches the four documented tokens exactly. [`Self::from_token`] reads off the wire and so
256 /// takes a token as loosely as HTTP writes it.
257 // spec:ENC#compressing-a-request-body
258 pub fn from_option(value: &str) -> Option<Self> {
259 match value {
260 "gzip" => Some(Self::Gzip),
261 "deflate" => Some(Self::Deflate),
262 "br" => Some(Self::Brotli),
263 "zstd" => Some(Self::Zstd),
264 _ => None,
265 }
266 }
267
268 /// The wire token for this coding in a `Content-Encoding`.
269 pub fn token(&self) -> &str {
270 match self {
271 Self::Gzip => "gzip",
272 Self::Deflate => "deflate",
273 Self::Brotli => "br",
274 Self::Zstd => "zstd",
275 Self::Other(token) => token,
276 }
277 }
278
279 /// Read a content-coding token, case-insensitively.
280 ///
281 /// Anything this crate does not decode, `identity` included, becomes
282 /// [`Other`](Self::Other); see [`is_supported`](Self::is_supported).
283 pub fn from_token(token: &str) -> Self {
284 let token = token.trim();
285 if token.eq_ignore_ascii_case("gzip") || token.eq_ignore_ascii_case("x-gzip") {
286 Self::Gzip
287 } else if token.eq_ignore_ascii_case("deflate") {
288 Self::Deflate
289 } else if token.eq_ignore_ascii_case("br") {
290 Self::Brotli
291 } else if token.eq_ignore_ascii_case("zstd") {
292 Self::Zstd
293 } else {
294 Self::Other(token.to_ascii_lowercase())
295 }
296 }
297
298 /// Whether this crate can decode this coding.
299 pub fn is_supported(&self) -> bool {
300 !matches!(self, Self::Other(_))
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 use http::header::HeaderValue;
309
310 fn headers(value: &str) -> HeaderMap {
311 let mut headers = HeaderMap::new();
312 headers.insert(CONTENT_ENCODING, HeaderValue::from_str(value).unwrap());
313 headers
314 }
315
316 #[test]
317 fn a_layered_coding_is_added_last() {
318 let existing = ContentEncoding::from(&headers("gzip"));
319 let layered = existing.layer(Coding::Zstd);
320 assert_eq!(layered.to_string(), "gzip, zstd");
321 assert_eq!(
322 ContentEncoding::from(&headers("gzip, br"))
323 .layer(Coding::Deflate)
324 .to_string(),
325 "gzip, br, deflate"
326 );
327
328 // Layering leaves what it was called on alone.
329 assert_eq!(existing.to_string(), "gzip");
330 }
331
332 #[test]
333 fn a_request_declaring_nothing_carries_only_the_layered_coding() {
334 assert_eq!(
335 ContentEncoding::default().layer(Coding::Brotli).to_string(),
336 "br"
337 );
338 // An empty or blank header declares nothing.
339 for value in ["", " "] {
340 assert_eq!(
341 ContentEncoding::from(&headers(value))
342 .layer(Coding::Gzip)
343 .to_string(),
344 "gzip"
345 );
346 }
347 }
348
349 #[test]
350 fn a_coding_this_crate_cannot_apply_is_still_declarable() {
351 // The caller compressed in a coding of their own and declared it; Faith layers gzip over
352 // the top, and the header names both in the order they were applied.
353 let layered = ContentEncoding::from(&headers("custom-thing")).layer(Coding::Gzip);
354 assert_eq!(layered.to_string(), "custom-thing, gzip");
355 assert_eq!(
356 layered.codings(),
357 [Coding::Other("custom-thing".into()), Coding::Gzip]
358 );
359
360 // Applying it is another matter: this crate has no encoder for it.
361 assert!(
362 futures::executor::block_on(crate::request::compress_buffer(
363 b"x",
364 Coding::Other("custom-thing".into())
365 ))
366 .is_err()
367 );
368 }
369
370 #[test]
371 fn nothing_to_declare_makes_no_header() {
372 assert!(ContentEncoding::default().to_header_value().is_none());
373 assert_eq!(
374 ContentEncoding::default()
375 .layer(Coding::Gzip)
376 .to_header_value()
377 .unwrap(),
378 "gzip"
379 );
380 }
381
382 #[test]
383 fn the_compress_option_names_a_coding_by_its_wire_token() {
384 assert_eq!(Coding::from_option("gzip"), Some(Coding::Gzip));
385 assert_eq!(Coding::from_option("deflate"), Some(Coding::Deflate));
386 assert_eq!(Coding::from_option("br"), Some(Coding::Brotli));
387 assert_eq!(Coding::from_option("zstd"), Some(Coding::Zstd));
388 }
389 #[test]
390 fn the_compress_option_matches_its_tokens_exactly() {
391 // Loose on the wire, exact as an API: `x-gzip` and a shouted token are read off a
392 // `Content-Encoding` but refused as option values.
393 assert_eq!(Coding::from_token("x-gzip"), Coding::Gzip);
394 assert_eq!(Coding::from_option("x-gzip"), None);
395 assert_eq!(Coding::from_token("GZIP"), Coding::Gzip);
396 assert_eq!(Coding::from_option("GZIP"), None);
397 assert_eq!(Coding::from_option(" gzip"), None);
398 assert_eq!(Coding::from_option("brotli"), None);
399 assert_eq!(Coding::from_option("identity"), None);
400 assert_eq!(Coding::from_option(""), None);
401 }
402
403 #[test]
404 fn a_coding_this_crate_cannot_decode_is_still_named() {
405 let identity = Coding::from_token("identity");
406 assert_eq!(identity, Coding::Other("identity".into()));
407 assert!(!identity.is_supported());
408 assert_eq!(identity.token(), "identity");
409
410 // Lowercased, so two spellings of one coding are one value.
411 assert_eq!(Coding::from_token("LZMA"), Coding::from_token("lzma"));
412 assert!(Coding::Gzip.is_supported());
413 }
414}