json_matcher/
datetime.rs

1use crate::{JsonMatcher, JsonMatcherError};
2use chrono::{DateTime, Duration, FixedOffset, Utc};
3use chrono_tz::Tz;
4use serde_json::Value;
5
6fn parse_datetime_from_string(
7    s: &str,
8    timezone: Option<&str>,
9) -> Result<DateTime<FixedOffset>, String> {
10    let datetime = match DateTime::parse_from_rfc3339(s) {
11        Ok(x) => x,
12        Err(e) => {
13            // if original parse fails, this might be because value does not have its own timezone, which parse_from_rfc3339 expects
14            // try to add UTC timezone first.
15            let parsed = DateTime::parse_from_rfc3339(&(s.to_owned() + "Z")).map_err(|_| {
16                // if this fails, then the value is not a valid RFC 3339 timestamp
17                // return original error
18                format!("Value cannot be parsed as an RFC 3339 timestamp: {e}")
19            })?;
20            // this succeeded, now if type has a timezone, we need to calculate the offset
21            let corrected = match timezone.as_ref() {
22                None => parsed,
23                Some(tz) => match tz.parse::<Tz>() {
24                    Ok(tz) => {
25                        // the timezone string in the type is a valid timezone name
26                        // so we interpret the original parsed value as if it were already in this timezone
27                        let with_timezone: DateTime<Tz> =
28                            parsed.naive_utc().and_local_timezone(tz).unwrap();
29                        with_timezone.fixed_offset()
30                    }
31                    Err(_) => {
32                        // the timezone string in the type is not a valid timezone name
33                        // so just return the utc-parsed value
34                        parsed
35                    }
36                },
37            };
38            corrected
39        }
40    };
41    Ok(datetime)
42}
43
44pub struct DateTimeStringMatcher {
45    lower_bound: Option<DateTime<Utc>>,
46    lower_bound_inclusive: bool,
47    upper_bound: Option<DateTime<Utc>>,
48    upper_bound_inclusive: bool,
49}
50
51impl DateTimeStringMatcher {
52    pub fn recent_utc() -> Self {
53        Self {
54            lower_bound: Some(Utc::now() - Duration::minutes(1)),
55            lower_bound_inclusive: true,
56            upper_bound: Some(Utc::now()),
57            upper_bound_inclusive: true,
58        }
59    }
60}
61
62impl JsonMatcher for DateTimeStringMatcher {
63    fn json_matches(&self, value: &Value) -> Vec<JsonMatcherError> {
64        let Value::String(as_str) = value else {
65            return vec![JsonMatcherError::at_root(
66                "Datetime value needs to be a string",
67            )];
68        };
69        let datetime = match parse_datetime_from_string(as_str, None) {
70            Ok(parsed) => parsed,
71            Err(err) => {
72                return vec![JsonMatcherError::at_root(format!(
73                    "Could not parse string as rfc3339 datetime: {}",
74                    err
75                ))];
76            }
77        };
78        if datetime.offset().utc_minus_local() != 0 {
79            return vec![JsonMatcherError::at_root("Datetime is not in UTC")];
80        }
81        if let Some(upper_bound) = self.upper_bound {
82            if self.upper_bound_inclusive {
83                if datetime.timestamp() > upper_bound.timestamp() {
84                    return vec![JsonMatcherError::at_root("Datetime is after upper bound")];
85                }
86            } else if datetime.timestamp() >= upper_bound.timestamp() {
87                return vec![JsonMatcherError::at_root(
88                    "Datetime is after or equal to upper bound",
89                )];
90            }
91        }
92        if let Some(lower_bound) = self.lower_bound {
93            if self.lower_bound_inclusive {
94                if datetime.timestamp() < lower_bound.timestamp() {
95                    return vec![JsonMatcherError::at_root(format!(
96                        "Datetime is before lower bound of {}",
97                        lower_bound.to_rfc3339()
98                    ))];
99                }
100            } else if datetime.timestamp() <= lower_bound.timestamp() {
101                return vec![JsonMatcherError::at_root(
102                    "Datetime is before or equal to lower bound",
103                )];
104            }
105        }
106        vec![]
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use crate::assert_jm;
113    use serde_json::json;
114
115    use super::*;
116
117    #[test]
118    fn test_date_time_string_matcher() {
119        let lower_bound = DateTime::parse_from_rfc3339("2024-01-05T10:00:00Z")
120            .unwrap()
121            .naive_utc()
122            .and_utc();
123        let upper_bound = DateTime::parse_from_rfc3339("2024-01-05T11:00:00Z")
124            .unwrap()
125            .naive_utc()
126            .and_utc();
127        let matcher = DateTimeStringMatcher {
128            lower_bound: Some(lower_bound),
129            lower_bound_inclusive: true,
130            upper_bound: Some(upper_bound),
131            upper_bound_inclusive: true,
132        };
133        // success cases
134        assert_jm!(json!("2024-01-05T10:00:00Z"), matcher);
135        assert_jm!(json!("2024-01-05T10:30:00Z"), matcher);
136        assert_jm!(json!("2024-01-05T11:00:00Z"), matcher);
137        // failure cases
138        assert_eq!(
139            matcher.json_matches(&json!(2)),
140            vec![JsonMatcherError::at_root(
141                "Datetime value needs to be a string"
142            )]
143        );
144        assert_eq!(
145            matcher.json_matches(&json!("bloop")),
146            vec![JsonMatcherError::at_root(
147                "Could not parse string as rfc3339 datetime: Value cannot be parsed as an RFC 3339 timestamp: input contains invalid characters"
148            )]
149        );
150        assert_eq!(
151            matcher.json_matches(&json!("2024-22-05T10:00:00Z")),
152            vec![JsonMatcherError::at_root(
153                "Could not parse string as rfc3339 datetime: Value cannot be parsed as an RFC 3339 timestamp: input is out of range"
154            )]
155        );
156        assert_eq!(
157            matcher.json_matches(&json!("2024-01-05T09:59:59Z")),
158            vec![JsonMatcherError::at_root(
159                "Datetime is before lower bound of 2024-01-05T10:00:00+00:00"
160            )]
161        );
162        assert_eq!(
163            matcher.json_matches(&json!("2024-01-05T11:00:01Z")),
164            vec![JsonMatcherError::at_root("Datetime is after upper bound")]
165        );
166        assert_eq!(
167            matcher.json_matches(&json!("2024-01-05T11:00:01-08:00")),
168            vec![JsonMatcherError::at_root("Datetime is not in UTC")]
169        );
170    }
171}