use crate::handler::BoxFuture;
use crate::method::Method;
use crate::middleware::{Middleware, Next};
use crate::request::Request;
use crate::response::Response;
use crate::status::Status;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone, Copy, Default)]
pub struct ETag {
weak: bool,
}
impl ETag {
pub fn new() -> Self {
Self::default()
}
pub fn weak() -> Self {
ETag { weak: true }
}
}
pub fn etag_for(body: &[u8]) -> String {
let mut hasher = std::hash::DefaultHasher::new();
body.hash(&mut hasher);
format!("\"{:x}-{:016x}\"", body.len(), hasher.finish())
}
fn none_match(header: &str, etag: &str) -> bool {
let ours = etag.trim_start_matches("W/");
header.split(',').map(str::trim).any(|candidate| {
candidate == "*" || candidate.trim_start_matches("W/") == ours
})
}
fn not_modified(response: Response) -> Response {
const KEEP: [&str; 7] =
["cache-control", "content-location", "date", "etag", "expires", "vary", "last-modified"];
let mut stripped = Response::new(Status::NOT_MODIFIED);
for (name, value) in response.headers.iter() {
if KEEP.contains(&name) {
stripped.headers.append(name, value);
}
}
stripped
}
impl Middleware for ETag {
fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {
if !matches!(request.method(), Method::Get | Method::Head) {
return next.run(request);
}
let if_none_match = request.header("if-none-match").map(str::to_string);
let if_modified_since =
request.header("if-modified-since").and_then(crate::date::parse_http_date);
let weak = self.weak;
Box::pin(async move {
let mut response = next.run(request).await;
if response.status != Status::OK || response.body.is_empty() {
return response;
}
if !response.headers.contains("etag") {
let tag = etag_for(&response.body);
response.headers.set("etag", if weak { format!("W/{tag}") } else { tag });
}
let etag = response.headers.get("etag").unwrap_or_default().to_string();
if let Some(header) = if_none_match {
return if none_match(&header, &etag) { not_modified(response) } else { response };
}
if let (Some(since), Some(modified)) = (
if_modified_since,
response.headers.get("last-modified").and_then(crate::date::parse_http_date),
) && modified <= since
{
return not_modified(response);
}
response
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::router::Router;
use crate::testing::TestClient;
fn client(etag: ETag) -> TestClient {
let mut router = Router::new();
router.middleware(etag);
router.get("/report", |_req: Request| async {
Response::json(rustlavel_core::Json::object([("total", rustlavel_core::Json::from("42"))]))
.with_header("cache-control", "private, max-age=0")
});
router.get("/dated", |_req: Request| async {
Response::text("dated").with_header("last-modified", "Sun, 06 Nov 1994 08:49:37 GMT")
});
router.get("/own-tag", |_req: Request| async {
Response::text("v7").with_header("etag", "\"version-7\"")
});
router.get("/empty", |_req: Request| async { Response::no_content() });
router.get("/missing", |_req: Request| async { Response::not_found().with_text("no") });
router.post("/report", |_req: Request| async { Response::text("created") });
TestClient::new(router)
}
#[tokio::test]
async fn a_successful_get_is_tagged() {
let response = client(ETag::new()).get("/report").await;
let tag = response.header("etag").expect("an etag").to_string();
assert!(tag.starts_with('"') && tag.ends_with('"'), "a strong tag is quoted: {tag}");
assert_eq!(tag, etag_for(response.body().as_bytes()));
}
#[tokio::test]
async fn the_same_body_gives_the_same_tag_and_a_different_body_a_different_one() {
assert_eq!(etag_for(b"hello"), etag_for(b"hello"));
assert_ne!(etag_for(b"hello"), etag_for(b"hello!"));
assert_ne!(etag_for(b"ab"), etag_for(b"ba"));
}
#[tokio::test]
async fn a_matching_if_none_match_is_answered_with_304_and_no_body() {
let client = client(ETag::new());
let first = client.get("/report").await;
let tag = first.header("etag").unwrap().to_string();
let request = Request::new(Method::Get, "/report").with_header("if-none-match", &tag);
let second = client.send(request).await;
let second = second.assert_status(304);
assert_eq!(second.body(), "");
assert_eq!(second.header("etag"), Some(tag.as_str()), "the tag travels with the 304");
assert_eq!(second.header("cache-control"), Some("private, max-age=0"));
assert_eq!(second.header("content-type"), None, "a 304 describes no body");
assert_eq!(second.header("content-length"), None);
}
#[tokio::test]
async fn a_stale_tag_gets_the_full_response() {
let request = Request::new(Method::Get, "/report").with_header("if-none-match", "\"something-old\"");
let response = client(ETag::new()).send(request).await;
let response = response.assert_ok();
assert!(response.body().contains("42"));
}
#[tokio::test]
async fn a_list_of_tags_and_a_star_both_match() {
let tag = etag_for(b"x");
assert!(none_match(&format!("\"other\", {tag}"), &tag));
assert!(none_match("*", &tag));
assert!(!none_match("\"other\"", &tag));
}
#[tokio::test]
async fn weak_and_strong_forms_of_the_same_tag_compare_equal() {
assert!(none_match("W/\"abc\"", "\"abc\""));
assert!(none_match("\"abc\"", "W/\"abc\""));
}
#[tokio::test]
async fn the_weak_variant_emits_a_weak_tag() {
let response = client(ETag::weak()).get("/report").await;
assert!(response.header("etag").unwrap().starts_with("W/\""));
}
#[tokio::test]
async fn a_handlers_own_tag_is_respected() {
let client = client(ETag::new());
assert_eq!(client.get("/own-tag").await.header("etag"), Some("\"version-7\""));
let request = Request::new(Method::Get, "/own-tag").with_header("if-none-match", "\"version-7\"");
client.send(request).await.assert_status(304);
}
#[tokio::test]
async fn if_modified_since_is_honoured_against_last_modified() {
let client = client(ETag::new());
let later = Request::new(Method::Get, "/dated").with_header("if-modified-since", "Mon, 07 Nov 1994 00:00:00 GMT");
client.send(later).await.assert_status(304);
let same = Request::new(Method::Get, "/dated").with_header("if-modified-since", "Sun, 06 Nov 1994 08:49:37 GMT");
client.send(same).await.assert_status(304);
let earlier = Request::new(Method::Get, "/dated").with_header("if-modified-since", "Sat, 05 Nov 1994 00:00:00 GMT");
client.send(earlier).await.assert_ok();
}
#[tokio::test]
async fn an_unreadable_if_modified_since_is_ignored() {
let request = Request::new(Method::Get, "/dated").with_header("if-modified-since", "last tuesday");
client(ETag::new()).send(request).await.assert_ok();
}
#[tokio::test]
async fn if_none_match_takes_precedence_over_the_date() {
let request = Request::new(Method::Get, "/dated")
.with_header("if-none-match", "\"stale\"")
.with_header("if-modified-since", "Mon, 07 Nov 1994 00:00:00 GMT");
client(ETag::new()).send(request).await.assert_ok();
}
#[tokio::test]
async fn only_successful_bodies_are_tagged() {
let client = client(ETag::new());
assert_eq!(client.get("/empty").await.header("etag"), None);
assert_eq!(client.get("/missing").await.header("etag"), None);
}
#[tokio::test]
async fn writes_are_never_tagged_or_short_circuited() {
let request = Request::new(Method::Post, "/report").with_header("if-none-match", "*");
let response = client(ETag::new()).send(request).await;
let response = response.assert_ok();
assert_eq!(response.header("etag"), None);
assert_eq!(response.body(), "created");
}
#[tokio::test]
async fn head_is_tagged_like_get() {
let request = Request::new(Method::Head, "/report");
let response = client(ETag::new()).send(request).await;
assert!(response.header("etag").is_some());
}
}