use crate::{Error, HttpRequest, HttpResponse};
use std::fmt;
use std::hash::{Hash, Hasher};
use std::time::SystemTime;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ETag {
pub value: String,
pub weak: bool,
}
impl ETag {
pub fn strong(value: impl Into<String>) -> Self {
Self {
value: value.into(),
weak: false,
}
}
pub fn weak(value: impl Into<String>) -> Self {
Self {
value: value.into(),
weak: true,
}
}
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim();
let (weak, value_part) = if s.starts_with("W/") || s.starts_with("w/") {
(true, &s[2..])
} else {
(false, s)
};
let value = value_part.strip_prefix('"')?.strip_suffix('"')?.to_string();
Some(Self { value, weak })
}
pub fn from_bytes(data: &[u8]) -> Self {
use std::collections::hash_map::DefaultHasher;
let mut hasher = DefaultHasher::new();
data.hash(&mut hasher);
let hash = hasher.finish();
Self::strong(format!("{:x}", hash))
}
pub fn weak_from_bytes(data: &[u8]) -> Self {
let mut etag = Self::from_bytes(data);
etag.weak = true;
etag
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
Self::from_bytes(s.as_bytes())
}
pub fn from_file_metadata(size: u64, modified: SystemTime) -> Self {
let modified_unix = modified
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Self::strong(format!("{:x}-{:x}", size, modified_unix))
}
pub fn from_version(version: u64) -> Self {
Self::strong(format!("v{}", version))
}
pub fn to_header_value(&self) -> String {
if self.weak {
format!("W/\"{}\"", self.value)
} else {
format!("\"{}\"", self.value)
}
}
pub fn strong_match(&self, other: &ETag) -> bool {
!self.weak && !other.weak && self.value == other.value
}
pub fn weak_match(&self, other: &ETag) -> bool {
self.value == other.value
}
}
impl fmt::Display for ETag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_header_value())
}
}
#[derive(Debug, Clone, Default)]
pub struct ETagList {
pub etags: Vec<ETag>,
pub any: bool,
}
impl ETagList {
pub fn new() -> Self {
Self::default()
}
pub fn any() -> Self {
Self {
etags: Vec::new(),
any: true,
}
}
pub fn parse(header: &str) -> Self {
let header = header.trim();
if header == "*" {
return Self::any();
}
let etags: Vec<ETag> = header
.split(',')
.filter_map(|s| ETag::parse(s.trim()))
.collect();
Self { etags, any: false }
}
pub fn contains_weak(&self, etag: &ETag) -> bool {
if self.any {
return true;
}
self.etags.iter().any(|e| e.weak_match(etag))
}
pub fn contains_strong(&self, etag: &ETag) -> bool {
if self.any {
return true;
}
self.etags.iter().any(|e| e.strong_match(etag))
}
pub fn is_empty(&self) -> bool {
!self.any && self.etags.is_empty()
}
}
#[derive(Debug, Clone, Default)]
pub struct ConditionalHeaders {
pub if_none_match: Option<ETagList>,
pub if_match: Option<ETagList>,
pub if_modified_since: Option<SystemTime>,
pub if_unmodified_since: Option<SystemTime>,
}
impl ConditionalHeaders {
pub fn from_request(request: &HttpRequest) -> Self {
let if_none_match = request
.headers
.get("If-None-Match")
.or_else(|| request.headers.get("if-none-match"))
.map(|h| ETagList::parse(h));
let if_match = request
.headers
.get("If-Match")
.or_else(|| request.headers.get("if-match"))
.map(|h| ETagList::parse(h));
let if_modified_since = request
.headers
.get("If-Modified-Since")
.or_else(|| request.headers.get("if-modified-since"))
.and_then(|h| httpdate::parse_http_date(h).ok());
let if_unmodified_since = request
.headers
.get("If-Unmodified-Since")
.or_else(|| request.headers.get("if-unmodified-since"))
.and_then(|h| httpdate::parse_http_date(h).ok());
Self {
if_none_match,
if_match,
if_modified_since,
if_unmodified_since,
}
}
pub fn is_not_modified(&self, etag: Option<&ETag>, last_modified: Option<SystemTime>) -> bool {
if let Some(ref if_none_match) = self.if_none_match
&& let Some(etag) = etag
{
return if_none_match.contains_weak(etag);
}
if let (Some(if_modified_since), Some(last_modified)) =
(self.if_modified_since, last_modified)
{
return last_modified <= if_modified_since;
}
false
}
pub fn precondition_failed(
&self,
etag: Option<&ETag>,
last_modified: Option<SystemTime>,
) -> bool {
if let Some(ref if_match) = self.if_match {
if let Some(etag) = etag {
return !if_match.contains_strong(etag);
} else {
return !if_match.any;
}
}
if let (Some(if_unmodified_since), Some(last_modified)) =
(self.if_unmodified_since, last_modified)
{
return last_modified > if_unmodified_since;
}
false
}
}
pub trait ConditionalRequest {
fn conditional_headers(&self) -> ConditionalHeaders;
fn if_none_match(&self) -> Option<ETagList>;
fn if_match(&self) -> Option<ETagList>;
fn if_modified_since(&self) -> Option<SystemTime>;
fn if_unmodified_since(&self) -> Option<SystemTime>;
fn if_none_match_matches(&self, etag: &ETag) -> bool;
fn if_match_matches(&self, etag: &ETag) -> bool;
fn not_modified_since(&self, last_modified: SystemTime) -> bool;
fn modified_since_precondition(&self, last_modified: SystemTime) -> bool;
fn evaluate_conditionals(
&self,
etag: Option<&ETag>,
last_modified: Option<SystemTime>,
) -> Option<u16>;
}
impl ConditionalRequest for HttpRequest {
fn conditional_headers(&self) -> ConditionalHeaders {
ConditionalHeaders::from_request(self)
}
fn if_none_match(&self) -> Option<ETagList> {
self.headers
.get("If-None-Match")
.or_else(|| self.headers.get("if-none-match"))
.map(|h| ETagList::parse(h))
}
fn if_match(&self) -> Option<ETagList> {
self.headers
.get("If-Match")
.or_else(|| self.headers.get("if-match"))
.map(|h| ETagList::parse(h))
}
fn if_modified_since(&self) -> Option<SystemTime> {
self.headers
.get("If-Modified-Since")
.or_else(|| self.headers.get("if-modified-since"))
.and_then(|h| httpdate::parse_http_date(h).ok())
}
fn if_unmodified_since(&self) -> Option<SystemTime> {
self.headers
.get("If-Unmodified-Since")
.or_else(|| self.headers.get("if-unmodified-since"))
.and_then(|h| httpdate::parse_http_date(h).ok())
}
fn if_none_match_matches(&self, etag: &ETag) -> bool {
self.if_none_match()
.map(|list| list.contains_weak(etag))
.unwrap_or(false)
}
fn if_match_matches(&self, etag: &ETag) -> bool {
match self.if_match() {
Some(list) => list.contains_strong(etag),
None => true, }
}
fn not_modified_since(&self, last_modified: SystemTime) -> bool {
self.if_modified_since()
.map(|since| last_modified <= since)
.unwrap_or(false)
}
fn modified_since_precondition(&self, last_modified: SystemTime) -> bool {
self.if_unmodified_since()
.map(|since| last_modified > since)
.unwrap_or(false)
}
fn evaluate_conditionals(
&self,
etag: Option<&ETag>,
last_modified: Option<SystemTime>,
) -> Option<u16> {
let headers = self.conditional_headers();
if headers.precondition_failed(etag, last_modified) {
return Some(412);
}
let method = self.method.to_uppercase();
let is_safe = method == "GET" || method == "HEAD";
if is_safe {
if headers.is_not_modified(etag, last_modified) {
return Some(304);
}
} else if let (Some(if_none_match), Some(etag)) = (&headers.if_none_match, etag) {
if if_none_match.contains_weak(etag) {
return Some(412);
}
}
None
}
}
pub trait ConditionalResponse {
fn with_etag(self, etag: &ETag) -> Self;
fn with_last_modified(self, time: SystemTime) -> Self;
fn not_modified() -> Self;
fn not_modified_with_etag(etag: &ETag) -> Self;
fn precondition_failed() -> Self;
fn precondition_failed_with_message(message: &str) -> Self;
}
impl ConditionalResponse for HttpResponse {
fn with_etag(mut self, etag: &ETag) -> Self {
self.headers
.insert("ETag".to_string(), etag.to_header_value());
self
}
fn with_last_modified(mut self, time: SystemTime) -> Self {
let formatted = httpdate::fmt_http_date(time);
self.headers.insert("Last-Modified".to_string(), formatted);
self
}
fn not_modified() -> Self {
Self::new(304)
}
fn not_modified_with_etag(etag: &ETag) -> Self {
let mut response = Self::new(304);
response
.headers
.insert("ETag".to_string(), etag.to_header_value());
response
}
fn precondition_failed() -> Self {
Self::new(412)
}
fn precondition_failed_with_message(message: &str) -> Self {
let body = serde_json::json!({
"error": "Precondition Failed",
"message": message,
"status": 412
});
let mut response = Self::new(412);
if let Ok(body_bytes) = serde_json::to_vec(&body) {
response.body = body_bytes;
response
.headers
.insert("Content-Type".to_string(), "application/json".to_string());
}
response
}
}
pub fn check_conditionals(
request: &HttpRequest,
etag: Option<&ETag>,
last_modified: Option<SystemTime>,
) -> Option<HttpResponse> {
match request.evaluate_conditionals(etag, last_modified) {
Some(304) => {
let mut response = HttpResponse::not_modified();
if let Some(etag) = etag {
response = response.with_etag(etag);
}
if let Some(lm) = last_modified {
response = response.with_last_modified(lm);
}
Some(response)
}
Some(412) => Some(HttpResponse::precondition_failed_with_message(
"Resource has been modified",
)),
_ => None,
}
}
pub fn cacheable_response<T: serde::Serialize>(
data: &T,
etag: &ETag,
last_modified: Option<SystemTime>,
) -> Result<HttpResponse, Error> {
let mut response = HttpResponse::ok().with_json(data)?.with_etag(etag);
if let Some(lm) = last_modified {
response = response.with_last_modified(lm);
}
response.headers.insert(
"Cache-Control".to_string(),
"private, must-revalidate".to_string(),
);
response
.headers
.insert("Vary".to_string(), "Accept, Accept-Encoding".to_string());
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_etag_strong() {
let etag = ETag::strong("abc123");
assert!(!etag.weak);
assert_eq!(etag.value, "abc123");
assert_eq!(etag.to_header_value(), "\"abc123\"");
}
#[test]
fn test_etag_weak() {
let etag = ETag::weak("abc123");
assert!(etag.weak);
assert_eq!(etag.value, "abc123");
assert_eq!(etag.to_header_value(), "W/\"abc123\"");
}
#[test]
fn test_etag_parse_strong() {
let etag = ETag::parse("\"abc123\"").unwrap();
assert!(!etag.weak);
assert_eq!(etag.value, "abc123");
}
#[test]
fn test_etag_parse_weak() {
let etag = ETag::parse("W/\"abc123\"").unwrap();
assert!(etag.weak);
assert_eq!(etag.value, "abc123");
}
#[test]
fn test_etag_parse_weak_lowercase() {
let etag = ETag::parse("w/\"abc123\"").unwrap();
assert!(etag.weak);
assert_eq!(etag.value, "abc123");
}
#[test]
fn test_etag_from_bytes() {
let data = b"Hello, World!";
let etag1 = ETag::from_bytes(data);
let etag2 = ETag::from_bytes(data);
assert_eq!(etag1.value, etag2.value);
assert!(!etag1.weak);
}
#[test]
fn test_etag_from_version() {
let etag = ETag::from_version(42);
assert_eq!(etag.value, "v42");
assert!(!etag.weak);
}
#[test]
fn test_etag_strong_match() {
let e1 = ETag::strong("abc");
let e2 = ETag::strong("abc");
let e3 = ETag::weak("abc");
assert!(e1.strong_match(&e2));
assert!(!e1.strong_match(&e3)); }
#[test]
fn test_etag_weak_match() {
let e1 = ETag::strong("abc");
let e2 = ETag::weak("abc");
assert!(e1.weak_match(&e2)); }
#[test]
fn test_etag_list_parse() {
let list = ETagList::parse("\"abc\", \"def\", W/\"ghi\"");
assert_eq!(list.etags.len(), 3);
assert!(!list.any);
}
#[test]
fn test_etag_list_parse_wildcard() {
let list = ETagList::parse("*");
assert!(list.any);
assert!(list.etags.is_empty());
}
#[test]
fn test_etag_list_contains_weak() {
let list = ETagList::parse("\"abc\", W/\"def\"");
let strong_abc = ETag::strong("abc");
let weak_abc = ETag::weak("abc");
let strong_xyz = ETag::strong("xyz");
assert!(list.contains_weak(&strong_abc));
assert!(list.contains_weak(&weak_abc)); assert!(!list.contains_weak(&strong_xyz));
}
#[test]
fn test_etag_list_contains_strong() {
let list = ETagList::parse("\"abc\", W/\"def\"");
let strong_abc = ETag::strong("abc");
let weak_abc = ETag::weak("abc");
let strong_def = ETag::strong("def");
assert!(list.contains_strong(&strong_abc));
assert!(!list.contains_strong(&weak_abc)); assert!(!list.contains_strong(&strong_def)); }
#[test]
fn test_etag_list_wildcard_contains() {
let list = ETagList::any();
let etag = ETag::strong("anything");
assert!(list.contains_weak(&etag));
assert!(list.contains_strong(&etag));
}
#[test]
fn test_etag_list_wildcard_matches_weak_etag() {
let list = ETagList::any();
let weak = ETag::weak("abc123");
assert!(list.contains_strong(&weak));
assert!(list.contains_weak(&weak));
}
#[test]
fn test_if_match_wildcard_with_weak_etag_succeeds() {
let mut request = HttpRequest::new("PUT".to_string(), "/resource".to_string());
request
.headers
.insert("If-Match".to_string(), "*".to_string());
let weak = ETag::weak("abc123");
assert_eq!(request.evaluate_conditionals(Some(&weak), None), None);
}
#[test]
fn test_if_none_match_unsafe_method_412() {
for method in ["PUT", "POST", "DELETE", "PATCH"] {
let mut request = HttpRequest::new(method.to_string(), "/resource".to_string());
request
.headers
.insert("If-None-Match".to_string(), "\"abc123\"".to_string());
let etag = ETag::strong("abc123");
assert_eq!(
request.evaluate_conditionals(Some(&etag), None),
Some(412),
"expected 412 for {}",
method
);
}
}
#[test]
fn test_if_none_match_unsafe_method_no_match_proceeds() {
let mut request = HttpRequest::new("PUT".to_string(), "/resource".to_string());
request
.headers
.insert("If-None-Match".to_string(), "\"abc123\"".to_string());
let etag = ETag::strong("different");
assert_eq!(request.evaluate_conditionals(Some(&etag), None), None);
}
#[test]
fn test_conditional_headers_if_none_match() {
let mut request = HttpRequest::new("GET".to_string(), "/resource".to_string());
request
.headers
.insert("If-None-Match".to_string(), "\"abc123\"".to_string());
let headers = ConditionalHeaders::from_request(&request);
assert!(headers.if_none_match.is_some());
let etag = ETag::strong("abc123");
assert!(headers.is_not_modified(Some(&etag), None));
}
#[test]
fn test_conditional_headers_if_match() {
let mut request = HttpRequest::new("PUT".to_string(), "/resource".to_string());
request
.headers
.insert("If-Match".to_string(), "\"abc123\"".to_string());
let headers = ConditionalHeaders::from_request(&request);
assert!(headers.if_match.is_some());
let matching = ETag::strong("abc123");
let non_matching = ETag::strong("xyz789");
assert!(!headers.precondition_failed(Some(&matching), None));
assert!(headers.precondition_failed(Some(&non_matching), None));
}
#[test]
fn test_request_if_none_match_matches() {
let mut request = HttpRequest::new("GET".to_string(), "/resource".to_string());
request
.headers
.insert("If-None-Match".to_string(), "\"abc123\"".to_string());
let matching = ETag::strong("abc123");
let non_matching = ETag::strong("xyz789");
assert!(request.if_none_match_matches(&matching));
assert!(!request.if_none_match_matches(&non_matching));
}
#[test]
fn test_request_if_match_matches() {
let mut request = HttpRequest::new("PUT".to_string(), "/resource".to_string());
request
.headers
.insert("If-Match".to_string(), "\"abc123\"".to_string());
let matching = ETag::strong("abc123");
let non_matching = ETag::strong("xyz789");
assert!(request.if_match_matches(&matching));
assert!(!request.if_match_matches(&non_matching));
}
#[test]
fn test_request_evaluate_conditionals_304() {
let mut request = HttpRequest::new("GET".to_string(), "/resource".to_string());
request
.headers
.insert("If-None-Match".to_string(), "\"abc123\"".to_string());
let etag = ETag::strong("abc123");
assert_eq!(request.evaluate_conditionals(Some(&etag), None), Some(304));
}
#[test]
fn test_request_evaluate_conditionals_412() {
let mut request = HttpRequest::new("PUT".to_string(), "/resource".to_string());
request
.headers
.insert("If-Match".to_string(), "\"abc123\"".to_string());
let etag = ETag::strong("xyz789");
assert_eq!(request.evaluate_conditionals(Some(&etag), None), Some(412));
}
#[test]
fn test_request_evaluate_conditionals_proceed() {
let request = HttpRequest::new("GET".to_string(), "/resource".to_string());
let etag = ETag::strong("abc123");
assert_eq!(request.evaluate_conditionals(Some(&etag), None), None);
}
#[test]
fn test_response_with_etag() {
let etag = ETag::strong("abc123");
let response = HttpResponse::ok().with_etag(&etag);
assert_eq!(
response.headers.get("ETag"),
Some(&"\"abc123\"".to_string())
);
}
#[test]
fn test_response_not_modified() {
let response = HttpResponse::not_modified();
assert_eq!(response.status, 304);
}
#[test]
fn test_response_precondition_failed() {
let response = HttpResponse::precondition_failed();
assert_eq!(response.status, 412);
}
#[test]
fn test_check_conditionals_returns_304() {
let mut request = HttpRequest::new("GET".to_string(), "/resource".to_string());
request
.headers
.insert("If-None-Match".to_string(), "\"abc123\"".to_string());
let etag = ETag::strong("abc123");
let response = check_conditionals(&request, Some(&etag), None);
assert!(response.is_some());
assert_eq!(response.unwrap().status, 304);
}
#[test]
fn test_check_conditionals_returns_412() {
let mut request = HttpRequest::new("PUT".to_string(), "/resource".to_string());
request
.headers
.insert("If-Match".to_string(), "\"abc123\"".to_string());
let etag = ETag::strong("different");
let response = check_conditionals(&request, Some(&etag), None);
assert!(response.is_some());
assert_eq!(response.unwrap().status, 412);
}
#[test]
fn test_check_conditionals_returns_none() {
let request = HttpRequest::new("GET".to_string(), "/resource".to_string());
let etag = ETag::strong("abc123");
let response = check_conditionals(&request, Some(&etag), None);
assert!(response.is_none());
}
}