1use chrono::{DateTime, NaiveDateTime, Utc};
2use serde::Deserialize;
3
4const DB_DATE_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
6
7#[derive(Deserialize, PartialEq, Eq, Debug, Clone)]
8pub struct Prefetch {
9 pub data: Vec<u8>,
10 pub etag: String,
11 pub last_modified: String,
12}
13
14#[derive(Debug)]
15pub struct Headers {
16 pub if_none_match: Option<String>,
17 pub if_modified_since: Option<String>,
18}
19
20pub fn is_not_modified(
25 if_none_match: Option<&str>,
26 if_modified_since: Option<&str>,
27 etag: &str,
28 last_modified: &str,
29) -> bool {
30 match (if_none_match, if_modified_since) {
31 (Some(if_none_match), _) => etag_matches(if_none_match, etag),
32 (None, Some(if_modified_since)) => not_modified_since(last_modified, if_modified_since),
33 (None, None) => false,
34 }
35}
36
37fn etag_matches(if_none_match: &str, etag: &str) -> bool {
40 let etag = opaque_tag(etag);
41 if etag.is_empty() {
42 return false;
43 }
44 if_none_match
45 .split(',')
46 .map(str::trim)
47 .any(|tag| tag == "*" || opaque_tag(tag) == etag)
48}
49
50fn opaque_tag(etag: &str) -> &str {
51 let etag = etag.trim();
52 let etag = etag.strip_prefix("W/").unwrap_or(etag);
53 etag.strip_prefix('"')
54 .and_then(|e| e.strip_suffix('"'))
55 .unwrap_or(etag)
56}
57
58fn not_modified_since(last_modified: &str, if_modified_since: &str) -> bool {
59 match (parse_date(last_modified), parse_date(if_modified_since)) {
60 (Some(last_modified), Some(since)) => last_modified <= since,
61 _ => last_modified == if_modified_since,
62 }
63}
64
65fn parse_date(date: &str) -> Option<DateTime<Utc>> {
67 DateTime::parse_from_rfc2822(date)
68 .map(|date| date.with_timezone(&Utc))
69 .ok()
70 .or_else(|| {
71 NaiveDateTime::parse_from_str(date, DB_DATE_FORMAT)
72 .ok()
73 .map(|date| date.and_utc())
74 })
75}
76
77pub fn quote_etag(etag: &str) -> String {
79 format!("\"{}\"", opaque_tag(etag))
80}
81
82pub fn db_date_to_http_date(date: &str) -> String {
85 NaiveDateTime::parse_from_str(date, DB_DATE_FORMAT).map_or_else(
86 |_| date.to_string(),
87 |date| {
88 date.and_utc()
89 .format("%a, %d %b %Y %H:%M:%S GMT")
90 .to_string()
91 },
92 )
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 const ETAG: &str = "abc";
100 const DB_DATE: &str = "2026-10-30 12:00:00";
101 const HTTP_DATE: &str = "Fri, 30 Oct 2026 12:00:00 GMT";
102
103 #[test]
104 fn matching_etag_alone_is_not_modified() {
105 assert!(is_not_modified(Some("\"abc\""), None, ETAG, DB_DATE));
106 assert!(is_not_modified(Some("abc"), None, ETAG, DB_DATE));
107 assert!(is_not_modified(Some("W/\"abc\""), None, ETAG, DB_DATE));
108 assert!(is_not_modified(Some("\"x\", \"abc\""), None, ETAG, DB_DATE));
109 assert!(is_not_modified(Some("\"abc\""), None, "\"abc\"", DB_DATE));
110 }
111
112 #[test]
113 fn other_etag_is_modified() {
114 assert!(!is_not_modified(Some("\"abd\""), None, ETAG, DB_DATE));
115 assert!(!is_not_modified(Some("\"\""), None, "", DB_DATE));
116 }
117
118 #[test]
119 fn if_none_match_takes_precedence_over_if_modified_since() {
120 assert!(!is_not_modified(
121 Some("\"old\""),
122 Some(HTTP_DATE),
123 ETAG,
124 DB_DATE
125 ));
126 assert!(is_not_modified(
127 Some("\"abc\""),
128 Some("Thu, 01 Jan 2026 00:00:00 GMT"),
129 ETAG,
130 DB_DATE
131 ));
132 }
133
134 #[test]
135 fn if_modified_since_alone_compares_dates() {
136 assert!(is_not_modified(None, Some(HTTP_DATE), ETAG, DB_DATE));
137 assert!(is_not_modified(
138 None,
139 Some("Sat, 31 Oct 2026 12:00:00 GMT"),
140 ETAG,
141 DB_DATE
142 ));
143 assert!(!is_not_modified(
144 None,
145 Some("Fri, 30 Oct 2026 11:59:59 GMT"),
146 ETAG,
147 DB_DATE
148 ));
149 assert!(is_not_modified(None, Some(HTTP_DATE), ETAG, HTTP_DATE));
150 }
151
152 #[test]
153 fn if_modified_since_with_the_old_database_format_still_matches() {
154 assert!(is_not_modified(None, Some(DB_DATE), ETAG, DB_DATE));
155 }
156
157 #[test]
158 fn no_conditional_headers_is_modified() {
159 assert!(!is_not_modified(None, None, ETAG, DB_DATE));
160 }
161
162 #[test]
163 fn database_date_is_sent_as_http_date() {
164 assert_eq!(HTTP_DATE, db_date_to_http_date(DB_DATE));
165 assert_eq!(HTTP_DATE, db_date_to_http_date(HTTP_DATE));
166 }
167
168 #[test]
169 fn etag_is_quoted_once() {
170 assert_eq!("\"abc\"", quote_etag("abc"));
171 assert_eq!("\"abc\"", quote_etag("\"abc\""));
172 }
173}