icann_rdap_common/
httpdata.rs

1//! Code for handling HTTP caching.
2
3use buildstructor::Builder;
4use chrono::{DateTime, Duration, Utc};
5use serde::{Deserialize, Serialize};
6
7#[derive(Serialize, Deserialize, Clone, Debug, Builder, PartialEq, Eq)]
8pub struct HttpData {
9    pub content_length: Option<u64>,
10    pub content_type: Option<String>,
11    pub scheme: Option<String>,
12    pub host: String,
13    pub expires: Option<String>,
14    pub cache_control: Option<String>,
15    pub received: DateTime<Utc>,
16    pub status_code: u16,
17    pub location: Option<String>,
18    pub access_control_allow_origin: Option<String>,
19    pub access_control_allow_credentials: Option<String>,
20    pub strict_transport_security: Option<String>,
21    pub retry_after: Option<String>,
22}
23
24#[buildstructor::buildstructor]
25impl HttpData {
26    #[builder(entry = "now")]
27    #[allow(clippy::too_many_arguments)]
28    pub fn new_now(
29        content_length: Option<u64>,
30        content_type: Option<String>,
31        scheme: String,
32        host: String,
33        expires: Option<String>,
34        cache_control: Option<String>,
35        status_code: Option<u16>,
36        location: Option<String>,
37        access_control_allow_origin: Option<String>,
38        access_control_allow_credentials: Option<String>,
39        strict_transport_security: Option<String>,
40        retry_after: Option<String>,
41    ) -> Self {
42        Self {
43            content_length,
44            content_type,
45            scheme: Some(scheme),
46            host,
47            expires,
48            cache_control,
49            received: Utc::now(),
50            status_code: status_code.unwrap_or(200),
51            location,
52            access_control_allow_origin,
53            access_control_allow_credentials,
54            strict_transport_security,
55            retry_after,
56        }
57    }
58
59    #[builder(entry = "example")]
60    #[allow(clippy::too_many_arguments)]
61    pub fn new_example(
62        content_length: Option<u64>,
63        content_type: Option<String>,
64        expires: Option<String>,
65        cache_control: Option<String>,
66        status_code: Option<u16>,
67        location: Option<String>,
68        access_control_allow_origin: Option<String>,
69        access_control_allow_credentials: Option<String>,
70        strict_transport_security: Option<String>,
71        retry_after: Option<String>,
72    ) -> Self {
73        Self {
74            content_length,
75            content_type,
76            scheme: Some("http".to_string()),
77            host: "example.com".to_string(),
78            expires,
79            cache_control,
80            received: Utc::now(),
81            status_code: status_code.unwrap_or(200),
82            location,
83            access_control_allow_origin,
84            access_control_allow_credentials,
85            strict_transport_security,
86            retry_after,
87        }
88    }
89
90    pub fn is_expired(&self, max_age: i64) -> bool {
91        let now = Utc::now();
92        if now >= self.received + Duration::seconds(max_age) {
93            return true;
94        }
95        if let Some(cache_control) = &self.cache_control {
96            let cc_max_age = cache_control
97                .split(',')
98                .map(|s| s.trim())
99                .find(|s| s.starts_with("max-age="));
100            if let Some(cc_max_age) = cc_max_age {
101                let cc_max_age = cc_max_age.trim_start_matches("max-age=").parse::<i64>();
102                if let Ok(cc_max_age) = cc_max_age {
103                    return now >= self.received + Duration::seconds(cc_max_age);
104                }
105            }
106        }
107        if let Some(expires) = &self.expires {
108            let expire_time = DateTime::parse_from_rfc2822(expires);
109            if let Ok(expire_time) = expire_time {
110                return now >= expire_time;
111            } else {
112                return false;
113            }
114        }
115        false
116    }
117
118    pub fn should_cache(&self) -> bool {
119        if let Some(cache_control) = &self.cache_control {
120            return !cache_control
121                .split(',')
122                .map(|s| s.trim())
123                .any(|s| s.eq("no-store") || s.eq("no-cache"));
124        }
125        true
126    }
127
128    pub fn from_lines(lines: &[String]) -> Result<(Self, &[String]), serde_json::Error> {
129        let count = lines.iter().take_while(|s| !s.starts_with("---")).count();
130        let cache_data = lines
131            .iter()
132            .take(count)
133            .cloned()
134            .collect::<Vec<String>>()
135            .join("");
136        let cache_data = serde_json::from_str(&cache_data)?;
137        Ok((cache_data, &lines[count + 1..]))
138    }
139
140    pub fn to_lines(&self, data: &str) -> Result<String, serde_json::Error> {
141        let mut lines = serde_json::to_string(self)?;
142        lines.push_str("\n---\n");
143        lines.push_str(data);
144        Ok(lines)
145    }
146}
147
148#[cfg(test)]
149#[allow(non_snake_case)]
150mod tests {
151    use super::HttpData;
152    use chrono::Duration;
153    use chrono::Utc;
154    use rstest::rstest;
155
156    #[rstest]
157    #[case(HttpData::example().cache_control("max-age=0").build(), 100, true)]
158    #[case(HttpData::example().cache_control("max-age=100").build(), 0, true)]
159    #[case(HttpData::example().cache_control("max-age=100").build(), 50, false)]
160    #[case(HttpData::example().build(), 0, true)]
161    #[case(HttpData::example().build(), 100, false)]
162    #[case(HttpData::example().expires(Utc::now().to_rfc2822()).build(), 100, true)]
163    #[case(HttpData::example().expires((Utc::now() + Duration::seconds(50)).to_rfc2822()).build(), 100, false)]
164    #[case(HttpData::example().expires((Utc::now() + Duration::seconds(100)).to_rfc2822()).build(), 50, false)]
165    #[case(HttpData::example().cache_control("max-age=100").expires(Utc::now().to_rfc2822()).build(), 100, false)]
166    #[case(HttpData::example().cache_control("max-age=0").expires((Utc::now() + Duration::seconds(50)).to_rfc2822()).build(), 100, true)]
167    fn GIVEN_cache_data_and_max_age_WHEN_is_expired_THEN_correct(
168        #[case] cache_data: HttpData,
169        #[case] max_age: i64,
170        #[case] expected: bool,
171    ) {
172        // GIVEN in parameters
173
174        // WHEN
175        let actual = cache_data.is_expired(max_age);
176
177        // THEN
178        assert_eq!(actual, expected);
179    }
180
181    #[rstest]
182    #[case(HttpData::example().cache_control("no-cache").build(), false)]
183    #[case(HttpData::example().cache_control("no-store").build(), false)]
184    #[case(HttpData::example().cache_control("max-age=40").build(), true)]
185    fn GIVEN_cache_control_WHEN_should_cache_THEN_correc(
186        #[case] cache_data: HttpData,
187        #[case] expected: bool,
188    ) {
189        // GIVEN in parameters
190
191        // WHEN
192        let actual = cache_data.should_cache();
193
194        // THEN
195        assert_eq!(actual, expected);
196    }
197
198    #[test]
199    fn GIVEN_data_and_data_cache_WHEN_to_lines_THEN_format_correct() {
200        // GIVEN
201        let data = "foo";
202        let cache_data = HttpData::example().content_length(14).build();
203
204        // WHEN
205        let actual = cache_data.to_lines(data).unwrap();
206
207        // THEN
208        let expected = format!("{}\n---\nfoo", serde_json::to_string(&cache_data).unwrap());
209        assert_eq!(actual, expected);
210    }
211
212    #[test]
213    fn GIVEN_lines_WHEN_from_lines_THEN_parse_correctly() {
214        // GIVEN
215        let data = "foo";
216        let cache_data = HttpData::example().content_length(14).build();
217        let lines = cache_data
218            .to_lines(data)
219            .unwrap()
220            .split('\n')
221            .map(|s| s.to_string())
222            .collect::<Vec<String>>();
223
224        // WHEN
225        let actual = HttpData::from_lines(&lines).expect("parsing lines");
226
227        // THEN
228        assert_eq!(cache_data, actual.0);
229        assert_eq!(vec![data], actual.1);
230    }
231}