Skip to main content

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}