Skip to main content

rustlavel_http/
etag.rs

1//! Entity tags and conditional requests.
2//!
3//! A client that polls `GET /api/orders` every few seconds is, most of the
4//! time, downloading a body identical to the one it already has. With this
5//! middleware the response carries an `ETag`; the client sends it back as
6//! `If-None-Match`; and when nothing has changed the answer is a `304 Not
7//! Modified` with no body at all. The handler still runs — this is a bandwidth
8//! saving, not a compute saving — but the bytes stay home.
9//!
10//! ```ignore
11//! App::new()?.middleware(ETag::default())
12//! ```
13//!
14//! A handler that sets its own `ETag` or `Last-Modified` is left alone, and
15//! its validators are honoured. Both `If-None-Match` and `If-Modified-Since`
16//! are evaluated with the precedence RFC 9110 §13.2.2 gives them: when the
17//! client sends both, only the tag counts.
18
19use crate::handler::BoxFuture;
20use crate::method::Method;
21use crate::middleware::{Middleware, Next};
22use crate::request::Request;
23use crate::response::Response;
24use crate::status::Status;
25use std::hash::{Hash, Hasher};
26
27#[derive(Debug, Clone, Copy, Default)]
28pub struct ETag {
29    /// Emit `W/"…"` rather than a strong tag.
30    ///
31    /// A strong tag promises byte-for-byte identity, which is exactly what a
32    /// hash of the body provides — so the default is strong. Weak is for a
33    /// handler whose output legitimately varies in ways that do not matter
34    /// (a timestamp in a comment, say) and wants the cache to keep matching.
35    weak: bool,
36}
37
38impl ETag {
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    pub fn weak() -> Self {
44        ETag { weak: true }
45    }
46}
47
48/// A validator for a body: hex length, a dash, and a 64-bit hash.
49///
50/// The length is included so two bodies that happen to collide on the hash
51/// still differ unless they are also the same size. SipHash-1-3 from the
52/// standard library is not a cryptographic hash and does not need to be — an
53/// entity tag only has to change when the body changes, and a client cannot
54/// gain anything by forging one.
55pub fn etag_for(body: &[u8]) -> String {
56    let mut hasher = std::hash::DefaultHasher::new();
57    body.hash(&mut hasher);
58    format!("\"{:x}-{:016x}\"", body.len(), hasher.finish())
59}
60
61/// Whether any tag in an `If-None-Match` list matches ours.
62///
63/// The weak comparison of RFC 9110 §8.8.3.2: `W/` prefixes are ignored on
64/// both sides, because for a GET the question is "may I keep using what I
65/// have", and a weakly-equal representation answers yes.
66fn none_match(header: &str, etag: &str) -> bool {
67    let ours = etag.trim_start_matches("W/");
68    header.split(',').map(str::trim).any(|candidate| {
69        candidate == "*" || candidate.trim_start_matches("W/") == ours
70    })
71}
72
73fn not_modified(response: Response) -> Response {
74    // RFC 9110 §15.4.5: a 304 carries the headers that would have been sent
75    // with a 200 and that the cache needs to update its stored response —
76    // and nothing that describes a body, because there is none.
77    const KEEP: [&str; 7] =
78        ["cache-control", "content-location", "date", "etag", "expires", "vary", "last-modified"];
79
80    let mut stripped = Response::new(Status::NOT_MODIFIED);
81    for (name, value) in response.headers.iter() {
82        if KEEP.contains(&name) {
83            stripped.headers.append(name, value);
84        }
85    }
86    stripped
87}
88
89impl Middleware for ETag {
90    fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {
91        // Only a safe method has a representation to validate. A conditional
92        // PUT (`If-Match`) is a different mechanism, for lost-update protection,
93        // and belongs to the handler that knows the resource.
94        if !matches!(request.method(), Method::Get | Method::Head) {
95            return next.run(request);
96        }
97
98        let if_none_match = request.header("if-none-match").map(str::to_string);
99        let if_modified_since =
100            request.header("if-modified-since").and_then(crate::date::parse_http_date);
101        let weak = self.weak;
102
103        Box::pin(async move {
104            let mut response = next.run(request).await;
105
106            // Only a full, successful representation gets a tag. A 404 body is
107            // not a version of anything, and a 206 has its own rules.
108            if response.status != Status::OK || response.body.is_empty() {
109                return response;
110            }
111
112            if !response.headers.contains("etag") {
113                let tag = etag_for(&response.body);
114                response.headers.set("etag", if weak { format!("W/{tag}") } else { tag });
115            }
116
117            let etag = response.headers.get("etag").unwrap_or_default().to_string();
118
119            // If-None-Match wins outright when present, even if the date would
120            // have said otherwise (RFC 9110 §13.2.2 step 3).
121            if let Some(header) = if_none_match {
122                return if none_match(&header, &etag) { not_modified(response) } else { response };
123            }
124
125            if let (Some(since), Some(modified)) = (
126                if_modified_since,
127                response.headers.get("last-modified").and_then(crate::date::parse_http_date),
128            ) && modified <= since
129            {
130                return not_modified(response);
131            }
132
133            response
134        })
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::router::Router;
142    use crate::testing::TestClient;
143
144    fn client(etag: ETag) -> TestClient {
145        let mut router = Router::new();
146        router.middleware(etag);
147        router.get("/report", |_req: Request| async {
148            Response::json(rustlavel_core::Json::object([("total", rustlavel_core::Json::from("42"))]))
149                .with_header("cache-control", "private, max-age=0")
150        });
151        router.get("/dated", |_req: Request| async {
152            Response::text("dated").with_header("last-modified", "Sun, 06 Nov 1994 08:49:37 GMT")
153        });
154        router.get("/own-tag", |_req: Request| async {
155            Response::text("v7").with_header("etag", "\"version-7\"")
156        });
157        router.get("/empty", |_req: Request| async { Response::no_content() });
158        router.get("/missing", |_req: Request| async { Response::not_found().with_text("no") });
159        router.post("/report", |_req: Request| async { Response::text("created") });
160        TestClient::new(router)
161    }
162
163    #[tokio::test]
164    async fn a_successful_get_is_tagged() {
165        let response = client(ETag::new()).get("/report").await;
166        let tag = response.header("etag").expect("an etag").to_string();
167        assert!(tag.starts_with('"') && tag.ends_with('"'), "a strong tag is quoted: {tag}");
168        assert_eq!(tag, etag_for(response.body().as_bytes()));
169    }
170
171    #[tokio::test]
172    async fn the_same_body_gives_the_same_tag_and_a_different_body_a_different_one() {
173        assert_eq!(etag_for(b"hello"), etag_for(b"hello"));
174        assert_ne!(etag_for(b"hello"), etag_for(b"hello!"));
175        assert_ne!(etag_for(b"ab"), etag_for(b"ba"));
176    }
177
178    #[tokio::test]
179    async fn a_matching_if_none_match_is_answered_with_304_and_no_body() {
180        let client = client(ETag::new());
181        let first = client.get("/report").await;
182        let tag = first.header("etag").unwrap().to_string();
183
184        let request = Request::new(Method::Get, "/report").with_header("if-none-match", &tag);
185        let second = client.send(request).await;
186
187        let second = second.assert_status(304);
188        assert_eq!(second.body(), "");
189        assert_eq!(second.header("etag"), Some(tag.as_str()), "the tag travels with the 304");
190        assert_eq!(second.header("cache-control"), Some("private, max-age=0"));
191        assert_eq!(second.header("content-type"), None, "a 304 describes no body");
192        assert_eq!(second.header("content-length"), None);
193    }
194
195    #[tokio::test]
196    async fn a_stale_tag_gets_the_full_response() {
197        let request = Request::new(Method::Get, "/report").with_header("if-none-match", "\"something-old\"");
198        let response = client(ETag::new()).send(request).await;
199        let response = response.assert_ok();
200        assert!(response.body().contains("42"));
201    }
202
203    #[tokio::test]
204    async fn a_list_of_tags_and_a_star_both_match() {
205        let tag = etag_for(b"x");
206        assert!(none_match(&format!("\"other\", {tag}"), &tag));
207        assert!(none_match("*", &tag));
208        assert!(!none_match("\"other\"", &tag));
209    }
210
211    #[tokio::test]
212    async fn weak_and_strong_forms_of_the_same_tag_compare_equal() {
213        // Weak comparison, as RFC 9110 §8.8.3.2 requires for If-None-Match.
214        assert!(none_match("W/\"abc\"", "\"abc\""));
215        assert!(none_match("\"abc\"", "W/\"abc\""));
216    }
217
218    #[tokio::test]
219    async fn the_weak_variant_emits_a_weak_tag() {
220        let response = client(ETag::weak()).get("/report").await;
221        assert!(response.header("etag").unwrap().starts_with("W/\""));
222    }
223
224    #[tokio::test]
225    async fn a_handlers_own_tag_is_respected() {
226        let client = client(ETag::new());
227        assert_eq!(client.get("/own-tag").await.header("etag"), Some("\"version-7\""));
228
229        let request = Request::new(Method::Get, "/own-tag").with_header("if-none-match", "\"version-7\"");
230        client.send(request).await.assert_status(304);
231    }
232
233    #[tokio::test]
234    async fn if_modified_since_is_honoured_against_last_modified() {
235        let client = client(ETag::new());
236
237        let later = Request::new(Method::Get, "/dated").with_header("if-modified-since", "Mon, 07 Nov 1994 00:00:00 GMT");
238        client.send(later).await.assert_status(304);
239
240        let same = Request::new(Method::Get, "/dated").with_header("if-modified-since", "Sun, 06 Nov 1994 08:49:37 GMT");
241        client.send(same).await.assert_status(304);
242
243        let earlier = Request::new(Method::Get, "/dated").with_header("if-modified-since", "Sat, 05 Nov 1994 00:00:00 GMT");
244        client.send(earlier).await.assert_ok();
245    }
246
247    #[tokio::test]
248    async fn an_unreadable_if_modified_since_is_ignored() {
249        let request = Request::new(Method::Get, "/dated").with_header("if-modified-since", "last tuesday");
250        client(ETag::new()).send(request).await.assert_ok();
251    }
252
253    #[tokio::test]
254    async fn if_none_match_takes_precedence_over_the_date() {
255        // A fresh date says 304; a stale tag says 200. The tag wins.
256        let request = Request::new(Method::Get, "/dated")
257            .with_header("if-none-match", "\"stale\"")
258            .with_header("if-modified-since", "Mon, 07 Nov 1994 00:00:00 GMT");
259        client(ETag::new()).send(request).await.assert_ok();
260    }
261
262    #[tokio::test]
263    async fn only_successful_bodies_are_tagged() {
264        let client = client(ETag::new());
265        assert_eq!(client.get("/empty").await.header("etag"), None);
266        assert_eq!(client.get("/missing").await.header("etag"), None);
267    }
268
269    #[tokio::test]
270    async fn writes_are_never_tagged_or_short_circuited() {
271        let request = Request::new(Method::Post, "/report").with_header("if-none-match", "*");
272        let response = client(ETag::new()).send(request).await;
273        let response = response.assert_ok();
274        assert_eq!(response.header("etag"), None);
275        assert_eq!(response.body(), "created");
276    }
277
278    #[tokio::test]
279    async fn head_is_tagged_like_get() {
280        let request = Request::new(Method::Head, "/report");
281        let response = client(ETag::new()).send(request).await;
282        assert!(response.header("etag").is_some());
283    }
284}