Skip to main content

sofie_core/
registrations.rs

1//! Run by both `sofie check` and boot, so the two cannot drift.
2
3use std::ops::RangeInclusive;
4use std::time::Duration;
5
6/// Faster claims without pause and never lets a drain end; slower overflows the next due time.
7const INTERVALS: RangeInclusive<Duration> =
8    Duration::from_secs(1)..=Duration::from_hours(1000 * 365 * 24);
9
10pub const INTERVALS_ALLOWED: &str =
11    "a schedule runs at most once a second and at least once in 1000 years";
12
13#[derive(Clone, Copy, Debug)]
14pub enum Key<'a> {
15    /// Prefix included; `sofie check` knows no path for an area without a literal prefix.
16    Route {
17        method: &'a str,
18        path: Option<&'a str>,
19        name: Option<&'a str>,
20    },
21    Job {
22        name: &'a str,
23        every: Option<Duration>,
24    },
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum Problem {
29    SamePath(usize, usize),
30    SameRouteName(usize, usize),
31    SameJobName(usize, usize),
32    Interval(usize),
33}
34
35impl Problem {
36    #[must_use]
37    pub fn pair(self) -> Option<(usize, usize)> {
38        match self {
39            Self::SamePath(first, second)
40            | Self::SameRouteName(first, second)
41            | Self::SameJobName(first, second) => Some((first, second)),
42            Self::Interval(_) => None,
43        }
44    }
45}
46
47#[must_use]
48pub fn validate(keys: &[Key<'_>]) -> Vec<Problem> {
49    let mut problems: Vec<Problem> = keys
50        .iter()
51        .enumerate()
52        .filter(|(_, key)| runs_out_of_range(key))
53        .map(|(index, _)| Problem::Interval(index))
54        .collect();
55    let served: Vec<Option<String>> = keys.iter().map(served_at).collect();
56    let pairs = (0..keys.len()).flat_map(|second| (0..second).map(move |first| (first, second)));
57    for (first, second) in pairs {
58        match (keys[first], keys[second]) {
59            (
60                Key::Route { method, name, .. },
61                Key::Route {
62                    method: other_method,
63                    name: other_name,
64                    ..
65                },
66            ) => {
67                if method == other_method
68                    && routers_clash(served[first].as_deref(), served[second].as_deref())
69                {
70                    problems.push(Problem::SamePath(first, second));
71                }
72                if name.is_some() && name == other_name {
73                    problems.push(Problem::SameRouteName(first, second));
74                }
75            }
76            (Key::Job { name, .. }, Key::Job { name: other, .. }) if name == other => {
77                problems.push(Problem::SameJobName(first, second));
78            }
79            _ => {}
80        }
81    }
82    problems
83}
84
85fn runs_out_of_range(key: &Key<'_>) -> bool {
86    matches!(key, Key::Job { every: Some(every), .. } if !INTERVALS.contains(every))
87}
88
89fn served_at(key: &Key<'_>) -> Option<String> {
90    match key {
91        Key::Route { path, .. } => path.map(without_parameter_names),
92        Key::Job { .. } => None,
93    }
94}
95
96/// As the router refuses them: one path once parameters are erased, or a catch-all at a parameter.
97fn routers_clash(left: Option<&str>, right: Option<&str>) -> bool {
98    let (Some(left), Some(right)) = (left, right) else {
99        return false;
100    };
101    let common = left
102        .bytes()
103        .zip(right.bytes())
104        .take_while(|(left, right)| left == right)
105        .count();
106    left == right || left.as_bytes()[..common].ends_with(b"{")
107}
108
109fn without_parameter_names(path: &str) -> String {
110    path.split('/')
111        .map(|segment| match segment.strip_prefix('{') {
112            Some(parameter) if parameter.starts_with('*') => "{*}",
113            Some(_) => "{}",
114            None => segment,
115        })
116        .collect::<Vec<_>>()
117        .join("/")
118}
119
120#[cfg(test)]
121mod tests {
122    use std::time::Duration;
123
124    use super::{Key, Problem, validate};
125
126    fn route<'a>(method: &'a str, path: &'a str, name: &'a str) -> Key<'a> {
127        Key::Route {
128            method,
129            path: Some(path),
130            name: Some(name),
131        }
132    }
133
134    fn job(name: &str, every: Option<Duration>) -> Key<'_> {
135        Key::Job { name, every }
136    }
137
138    #[test]
139    fn distinct_routes_and_jobs_pass() {
140        let keys = [
141            route("GET", "/orders/{order}", "order.web.show"),
142            route("POST", "/orders/{order}", "order.web.cancel"),
143            route("GET", "/orders/new", "order.web.new"),
144            job("order.listeners.audit", None),
145            job("order.schedules.tidy", Some(Duration::from_mins(15))),
146        ];
147        assert_eq!(validate(&keys), []);
148    }
149
150    #[test]
151    fn paths_alike_but_for_parameter_names_clash() {
152        let keys = [
153            route("GET", "/account/orders/{order}", "order.customer.show"),
154            route("GET", "/account/orders/{id}", "order.web.show"),
155        ];
156        assert_eq!(validate(&keys), [Problem::SamePath(0, 1)]);
157    }
158
159    #[test]
160    fn a_parameter_and_a_catch_all_at_one_place_clash() {
161        let keys = [
162            route("GET", "/files/{file}", "storage.web.show"),
163            route("GET", "/files/{*rest}", "storage.web.serve"),
164        ];
165        assert_eq!(validate(&keys), [Problem::SamePath(0, 1)]);
166    }
167
168    #[test]
169    fn a_route_name_is_one_route_s() {
170        let keys = [
171            route("GET", "/orders", "order.web.list"),
172            route("GET", "/all-orders", "order.web.list"),
173        ];
174        assert_eq!(validate(&keys), [Problem::SameRouteName(0, 1)]);
175    }
176
177    #[test]
178    fn unnamed_routes_never_share_a_name() {
179        let unnamed = |path| Key::Route {
180            method: "GET",
181            path: Some(path),
182            name: None,
183        };
184        assert_eq!(validate(&[unnamed("/a"), unnamed("/b")]), []);
185    }
186
187    #[test]
188    fn a_route_whose_path_is_unknown_clashes_with_none() {
189        let unplaced = Key::Route {
190            method: "GET",
191            path: None,
192            name: Some("order.member.orders"),
193        };
194        let keys = [unplaced, route("GET", "/orders", "order.web.orders")];
195        assert_eq!(validate(&keys), []);
196    }
197
198    #[test]
199    fn a_job_name_is_one_job_s() {
200        let keys = [
201            job("tidy", Some(Duration::from_mins(1))),
202            route("GET", "/tidy", "tidy"),
203            job("tidy", None),
204        ];
205        assert_eq!(validate(&keys), [Problem::SameJobName(0, 2)]);
206    }
207
208    #[test]
209    fn a_schedule_runs_between_once_a_second_and_once_in_1000_years() {
210        let keys = [
211            job("eager", Some(Duration::from_millis(500))),
212            job("second", Some(Duration::from_secs(1))),
213            job("never", Some(Duration::from_hours(1001 * 365 * 24))),
214        ];
215        assert_eq!(
216            validate(&keys),
217            [Problem::Interval(0), Problem::Interval(2)]
218        );
219    }
220}