web_net/policy.rs
1//! Per-route cache policy, the cacheability report, and response
2//! compression.
3
4use serve_net::Outcome;
5
6use crate::RouteId;
7
8/// How a route's pages may be cached. Applied as `Cache-Control` unless the
9/// handler set one itself.
10///
11/// ```
12/// use web_net::{App, CachePolicy, get};
13///
14/// let app = App::builder()
15/// .route(get("/", |cx| cx.text(200, "home")).cache(CachePolicy::Private { max_age: 60 }))
16/// .build()?;
17/// let r = serve_net::testing::respond(&app, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
18/// assert_eq!(r.header("cache-control"), Some(&b"private, max-age=60"[..]));
19/// # Ok::<(), web_net::BuildError>(())
20/// ```
21#[non_exhaustive]
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
23pub enum CachePolicy {
24 /// No `Cache-Control` from web-net (the default): the handler decides.
25 #[default]
26 Unset,
27 /// `no-store`: never kept (account pages, anything personal).
28 NoStore,
29 /// `private, max-age=N`: the visitor's browser only.
30 Private {
31 /// Seconds.
32 max_age: u32,
33 },
34 /// `public, max-age=N` for everyone.
35 Public {
36 /// Seconds.
37 max_age: u32,
38 },
39 /// `public, max-age=N` for guests, `private, no-store` for signed-in
40 /// visitors: the same page, cached only while it is the same for
41 /// everyone. A cache in front of web-net may keep guests' copies
42 /// ([`Served::cache`]).
43 Guests {
44 /// Seconds.
45 max_age: u32,
46 },
47}
48
49impl CachePolicy {
50 /// The `Cache-Control` value for a visitor.
51 pub(crate) fn value(self, guest: bool) -> Option<String> {
52 match self {
53 Self::Unset => None,
54 Self::NoStore => Some("no-store".into()),
55 Self::Private { max_age } => Some(format!("private, max-age={max_age}")),
56 Self::Public { max_age } => Some(format!("public, max-age={max_age}")),
57 Self::Guests { max_age } if guest => Some(format!("public, max-age={max_age}")),
58 Self::Guests { .. } => Some("private, no-store".into()),
59 }
60 }
61}
62
63/// Whether a cache in front of web-net may keep this reply for guests.
64///
65/// ```
66/// use serve_net::{Envelope, HttpReply, HttpRequest, HttpService, Outcome};
67/// use web_net::{App, CachePolicy, Cacheable, get};
68///
69/// // A caching front that reads the report.
70/// struct Front(App);
71/// impl HttpService for Front {
72/// fn call(&self, req: &HttpRequest<'_>, env: &Envelope, reply: &mut HttpReply) -> Outcome {
73/// let served = self.0.respond(req, env, None, reply);
74/// assert_eq!(served.cache, Cacheable::Guests { max_age: 30 });
75/// served.outcome
76/// }
77/// }
78///
79/// let app = App::builder()
80/// .route(get("/", |cx| cx.text(200, "hi")).cache(CachePolicy::Guests { max_age: 30 }))
81/// .build()?;
82/// let r = serve_net::testing::respond(&Front(app), b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
83/// assert_eq!(r.text(), "hi");
84/// # Ok::<(), web_net::BuildError>(())
85/// ```
86#[non_exhaustive]
87#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
88pub enum Cacheable {
89 /// Do not keep it.
90 #[default]
91 No,
92 /// Keep it for guests' `GET` and `HEAD` of the same target, this long.
93 /// Said only when the route's policy allows it, the request was a
94 /// guest's `GET` or `HEAD`, the status is 200, and the reply sets no
95 /// cookie and varies on nothing but `Accept-Encoding`.
96 Guests {
97 /// Seconds.
98 max_age: u32,
99 },
100}
101
102/// What [`App::respond`](crate::App::respond) reports with the outcome.
103///
104/// ```
105/// use serve_net::{Envelope, HttpReply, HttpRequest, HttpService, Outcome};
106/// use web_net::{App, Cacheable, RouteId, get};
107///
108/// struct Front(App);
109/// impl HttpService for Front {
110/// fn call(&self, req: &HttpRequest<'_>, env: &Envelope, reply: &mut HttpReply) -> Outcome {
111/// let served = self.0.respond(req, env, None, reply);
112/// assert_eq!((served.route, served.cache), (Some(RouteId(0)), Cacheable::No));
113/// served.outcome
114/// }
115/// }
116///
117/// let app = App::builder().route(get("/", |cx| cx.text(200, "hi"))).build()?;
118/// let r = serve_net::testing::respond(&Front(app), b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
119/// assert_eq!(r.text(), "hi");
120/// # Ok::<(), web_net::BuildError>(())
121/// ```
122#[non_exhaustive]
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub struct Served {
125 /// serve-net's outcome.
126 pub outcome: Outcome,
127 /// The route that ran, if one did.
128 pub route: Option<RouteId>,
129 /// Whether a cache in front may keep the reply.
130 pub cache: Cacheable,
131}
132
133/// Response compression with gzip (compress-lib), for deployments where
134/// nothing in front compresses.
135///
136/// Compression together with secrets and reflected input in one page can
137/// leak the secrets (BREACH). csrf-sec masks its tokens per response, which
138/// is the defence for them; other secrets in compressed pages are the
139/// application's to consider.
140///
141/// ```
142/// use web_net::{App, Compression, get};
143///
144/// let app = App::builder()
145/// .compress(Compression::default().min_bytes(512).level(6))
146/// .route(get("/", |cx| cx.text(200, &"hello ".repeat(200))))
147/// .build()?;
148/// let r = serve_net::testing::respond(
149/// &app,
150/// b"GET / HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n",
151/// );
152/// assert_eq!(r.header("content-encoding"), Some(&b"gzip"[..]));
153/// # Ok::<(), web_net::BuildError>(())
154/// ```
155#[derive(Clone, Copy, Debug, PartialEq, Eq)]
156pub struct Compression {
157 pub(crate) min_bytes: usize,
158 pub(crate) level: u8,
159}
160
161impl Default for Compression {
162 fn default() -> Self {
163 Self {
164 min_bytes: 1024,
165 level: 6,
166 }
167 }
168}
169
170impl Compression {
171 /// Bodies smaller than this are sent as they are (default 1024).
172 ///
173 /// ```
174 /// use web_net::{App, Compression, get};
175 ///
176 /// // A 600-byte page is under the minimum, so it goes out as it is.
177 /// let app = App::builder()
178 /// .compress(Compression::default().min_bytes(4096))
179 /// .route(get("/", |cx| cx.text(200, &"hello ".repeat(100))))
180 /// .build()?;
181 /// let r = serve_net::testing::respond(
182 /// &app,
183 /// b"GET / HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n",
184 /// );
185 /// assert_eq!(r.header("content-encoding"), None);
186 /// # Ok::<(), web_net::BuildError>(())
187 /// ```
188 #[must_use]
189 pub fn min_bytes(mut self, bytes: usize) -> Self {
190 self.min_bytes = bytes;
191 self
192 }
193
194 /// The level, 1 (fastest) to 9 (smallest); default 6.
195 ///
196 /// ```
197 /// use web_net::{App, Compression, get};
198 ///
199 /// // Fastest; values outside 1 to 9 are clamped.
200 /// let app = App::builder()
201 /// .compress(Compression::default().level(1))
202 /// .route(get("/", |cx| cx.text(200, &"hello ".repeat(400))))
203 /// .build()?;
204 /// let r = serve_net::testing::respond(
205 /// &app,
206 /// b"GET / HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n",
207 /// );
208 /// assert_eq!(r.header("content-encoding"), Some(&b"gzip"[..]));
209 /// # Ok::<(), web_net::BuildError>(())
210 /// ```
211 #[must_use]
212 pub fn level(mut self, level: u8) -> Self {
213 self.level = level.clamp(1, 9);
214 self
215 }
216}
217
218/// Media types worth compressing.
219pub(crate) fn compressible(content_type: &[u8]) -> bool {
220 let t = content_type.split(|&b| b == b';').next().unwrap_or(b"");
221 let t = t.trim_ascii();
222 t.starts_with(b"text/")
223 || matches!(
224 t,
225 b"application/json"
226 | b"application/javascript"
227 | b"application/xml"
228 | b"application/manifest+json"
229 | b"application/rss+xml"
230 | b"application/atom+xml"
231 | b"image/svg+xml"
232 )
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn test_policy_values() {
241 assert_eq!(CachePolicy::Unset.value(true), None);
242 assert_eq!(
243 CachePolicy::Guests { max_age: 60 }.value(true).unwrap(),
244 "public, max-age=60"
245 );
246 assert_eq!(
247 CachePolicy::Guests { max_age: 60 }.value(false).unwrap(),
248 "private, no-store"
249 );
250 assert_eq!(
251 CachePolicy::Private { max_age: 5 }.value(false).unwrap(),
252 "private, max-age=5"
253 );
254 }
255
256 #[test]
257 fn test_compressible() {
258 assert!(compressible(b"text/html; charset=utf-8"));
259 assert!(compressible(b"application/json"));
260 assert!(!compressible(b"image/png"));
261 assert!(!compressible(b""));
262 }
263}