cronback_api_model/
schedule.rs1use 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")]
114fn 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 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 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 "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 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 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 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 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}