1use 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 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
48pub 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
61fn 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 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 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 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 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 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 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}