Skip to main content

kellnr_common/
prefetch.rs

1use chrono::{DateTime, NaiveDateTime, Utc};
2use serde::Deserialize;
3
4/// Format in which Kellnr stores dates in the database.
5const 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
20/// Whether a client that sent the conditional request headers already has the
21/// current index entry with the given `etag` and `last_modified` date, so that
22/// a 304 can be returned. Like RFC 7232, `If-None-Match` takes precedence over
23/// `If-Modified-Since`.
24pub 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
37/// Whether one of the entity tags in an `If-None-Match` header matches `etag`.
38/// The comparison is weak, so quotes and the `W/` prefix are ignored.
39fn 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
65/// Parse an HTTP date or a date in the database format.
66fn 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
77/// Quote an entity tag as required in the `ETag` header.
78pub fn quote_etag(etag: &str) -> String {
79    format!("\"{}\"", opaque_tag(etag))
80}
81
82/// Convert a date in the database format to an HTTP date for the
83/// `Last-Modified` header. Other values are returned unchanged.
84pub 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}