cronback_api_model/
schedule.rs

1use chrono::{DateTime, FixedOffset};
2#[cfg(feature = "dto")]
3use dto::{FromProto, IntoProto};
4use monostate::MustBe;
5use serde::{Deserialize, Serialize};
6use serde_with::skip_serializing_none;
7#[cfg(feature = "validation")]
8use validator::{Validate, ValidationError};
9
10#[cfg(feature = "validation")]
11use crate::validation_util::validation_error;
12
13#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
14#[cfg_attr(feature = "client", non_exhaustive)]
15#[cfg_attr(
16    feature = "dto",
17    derive(IntoProto, FromProto),
18    proto(target = "proto::trigger_proto::Schedule")
19)]
20#[serde(rename_all = "snake_case")]
21#[serde(untagged)]
22pub enum Schedule {
23    Recurring(Recurring),
24    RunAt(RunAt),
25}
26
27#[skip_serializing_none]
28#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
29#[cfg_attr(feature = "validation", derive(Validate))]
30#[cfg_attr(
31    feature = "dto",
32    derive(IntoProto, FromProto),
33    proto(target = "proto::trigger_proto::Recurring")
34)]
35#[cfg_attr(feature = "server", serde(deny_unknown_fields))]
36#[serde(rename_all = "snake_case")]
37pub struct Recurring {
38    #[serde(rename = "type")]
39    _kind: MustBe!("recurring"),
40    #[cfg_attr(
41        feature = "validation",
42        validate(custom = "validate_cron", required)
43    )]
44    #[cfg_attr(feature = "dto", proto(required))]
45    pub cron: Option<String>,
46    #[cfg_attr(feature = "validation", validate(custom = "validate_timezone"))]
47    #[cfg_attr(feature = "server", serde(default = "default_timezone"))]
48    #[cfg_attr(feature = "dto", proto(required))]
49    pub timezone: Option<String>,
50    #[cfg_attr(feature = "validation", validate(range(min = 1)))]
51    pub limit: Option<u64>,
52    pub remaining: Option<u64>,
53}
54
55#[skip_serializing_none]
56#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
57#[cfg_attr(feature = "validation", derive(Validate))]
58#[cfg_attr(
59    feature = "dto",
60    derive(IntoProto, FromProto),
61    proto(target = "proto::trigger_proto::RunAt")
62)]
63#[cfg_attr(feature = "server", serde(deny_unknown_fields))]
64#[serde(rename_all = "snake_case")]
65pub struct RunAt {
66    #[serde(rename = "type")]
67    _kind: MustBe!("timepoints"),
68    #[cfg_attr(
69        feature = "validation",
70        validate(
71            length(
72                min = 1,
73                max = 5000,
74                message = "must have at least one but with no more than 5000 \
75                           timepoints"
76            ),
77            custom = "validate_run_at"
78        )
79    )]
80    pub timepoints: Vec<DateTime<FixedOffset>>,
81    pub remaining: Option<u64>,
82}
83
84#[cfg(feature = "server")]
85fn default_timezone() -> Option<String> {
86    Some("Etc/UTC".to_string())
87}
88
89#[cfg(feature = "validation")]
90impl Validate for Schedule {
91    fn validate(&self) -> Result<(), validator::ValidationErrors> {
92        match self {
93            | Schedule::Recurring(recurring) => recurring.validate(),
94            | Schedule::RunAt(run_at) => run_at.validate(),
95        }
96    }
97}
98
99#[cfg(feature = "validation")]
100fn validate_cron(cron_pattern: &String) -> Result<(), ValidationError> {
101    use std::str::FromStr;
102
103    use cron::Schedule as CronSchedule;
104    if CronSchedule::from_str(cron_pattern).is_err() {
105        return Err(validation_error(
106            "invalid_cron_pattern",
107            format!("Invalid cron_pattern '{}'", cron_pattern),
108        ));
109    }
110    Ok(())
111}
112
113#[cfg(feature = "validation")]
114// Validate that run_at has no duplicates.
115fn validate_run_at(
116    run_at: &Vec<DateTime<FixedOffset>>,
117) -> Result<(), ValidationError> {
118    use std::collections::HashSet;
119
120    use chrono::Timelike;
121
122    let mut ts = HashSet::new();
123    for timepoint in run_at {
124        let trimmed = timepoint.with_nanosecond(0).unwrap();
125        if ts.contains(&trimmed) {
126            // Duplicate found!
127            return Err(validation_error(
128                "duplicate_run_at_value",
129                format!(
130                    "'{timepoint}' conflicts with other timepoints on the \
131                     list. Note that the precision is limited to seconds."
132                ),
133            ));
134        } else {
135            ts.insert(trimmed);
136        }
137    }
138    Ok(())
139}
140
141#[cfg(feature = "validation")]
142pub fn validate_timezone(
143    cron_timezone: &String,
144) -> Result<(), ValidationError> {
145    // validate timezone
146    use chrono_tz::Tz;
147    let tz: Result<Tz, _> = cron_timezone.parse();
148    if tz.is_err() {
149        return Err(validation_error(
150            "unrecognized_cron_timezone",
151            format!(
152                "Timezone unrecognized '{cron_timezone}'. A valid IANA \
153                 timezone string is required",
154            ),
155        ));
156    };
157    Ok(())
158}
159
160#[cfg(all(test, feature = "validation"))]
161mod tests {
162    use anyhow::Result;
163    use serde_json::json;
164
165    use super::*;
166
167    #[test]
168    fn validate_run_at() -> Result<()> {
169        let run_at = json!(
170            {
171                "type": "timepoints",
172                // a minute difference
173                "timepoints": [
174                    "2023-06-02T12:40:58+03:00",
175                    "2023-06-02T12:41:58+03:00"
176                ]
177            }
178        );
179        let parsed: RunAt = serde_json::from_value(run_at)?;
180        parsed.validate()?;
181        assert_eq!(2, parsed.timepoints.len());
182
183        // at least one is needed
184        let run_at = json!(
185            {
186                "type": "timepoints",
187                "timepoints": [ ]
188            }
189        );
190        let parsed: RunAt = serde_json::from_value(run_at)?;
191        let maybe_validated = parsed.validate();
192        assert!(maybe_validated.is_err());
193        assert_eq!(
194            "timepoints: must have at least one but with no more than 5000 \
195             timepoints"
196                .to_owned(),
197            maybe_validated.unwrap_err().to_string()
198        );
199
200        // no duplicates allowed
201        let run_at = json!(
202            {
203                "type": "timepoints",
204                "timepoints": [
205                    "2023-06-02T12:40:58+03:00",
206                    "2023-06-02T12:40:58+03:00"
207                ]
208            }
209        );
210        let parsed: RunAt = serde_json::from_value(run_at)?;
211        let maybe_validated = parsed.validate();
212        assert!(maybe_validated.is_err());
213        assert!(maybe_validated
214            .unwrap_err()
215            .to_string()
216            .starts_with("timepoints: "));
217        Ok(())
218    }
219
220    #[test]
221    fn validate_recurring() -> Result<()> {
222        // valid cron, every minute.
223        let recurring = json!(
224            {
225                "type": "recurring",
226                "cron": "0 * * * * *",
227            }
228        );
229        let parsed: Recurring = serde_json::from_value(recurring)?;
230        parsed.validate()?;
231        assert_eq!("0 * * * * *".to_owned(), parsed.cron.unwrap());
232        assert_eq!(Some("Etc/UTC".to_owned()), parsed.timezone);
233        assert!(parsed.limit.is_none());
234        assert!(parsed.remaining.is_none());
235
236        // invalid cron
237        let recurring = json!(
238            {
239                "type": "recurring",
240                "cron": "0 * invalid",
241            }
242        );
243        let parsed: Recurring = serde_json::from_value(recurring)?;
244        let maybe_validated = parsed.validate();
245        assert!(maybe_validated.is_err());
246        assert_eq!(
247            "cron: Invalid cron_pattern '0 * invalid'".to_owned(),
248            maybe_validated.unwrap_err().to_string()
249        );
250
251        Ok(())
252    }
253}