sofie_model/
permissions.rs1use std::collections::BTreeMap;
2
3use crate::naming::{RouteSite, snake_case};
4use crate::{Aggregate, App, AreaDefinition, KnownArea, Method, ModelError, Module, Route};
5
6const PERMITTED_PRINCIPAL: &str = "Permitted";
7
8const WHOLE_RECORD: &str = "*";
9const RECORD_ACTIONS: [&str; 4] = ["read", "create", "update", "delete"];
10const FIELD_ACTIONS: [&str; 2] = ["read", "update"];
11
12impl AreaDefinition {
13 #[must_use]
14 pub fn is_permitted(&self) -> bool {
15 self.principal
16 .last()
17 .is_some_and(|name| name == PERMITTED_PRINCIPAL)
18 }
19}
20
21impl KnownArea<'_> {
22 #[must_use]
23 pub fn is_permitted(&self) -> bool {
24 self.definition.is_some_and(AreaDefinition::is_permitted)
25 }
26}
27
28impl RouteSite<'_> {
29 #[must_use]
30 pub fn permission(&self, app: &App) -> Option<String> {
31 app.area(&self.area.area)
32 .is_some_and(|area| area.is_permitted())
33 .then(|| route_permission(self.module, self.route))
34 }
35}
36
37#[must_use]
38pub fn permission_constant(permission: &str) -> String {
39 let axes: Vec<&str> = permission
40 .split('.')
41 .skip(1)
42 .filter(|axis| *axis != WHOLE_RECORD)
43 .collect();
44 axes.join("_").to_ascii_uppercase()
45}
46
47pub fn refuse_colliding_constants(module: &Module) -> Result<(), ModelError> {
49 let mut constants: BTreeMap<String, String> = BTreeMap::new();
50 for permission in module_permissions(module) {
51 let constant = permission_constant(&permission);
52 if let Some(earlier) = constants.insert(constant.clone(), permission.clone()) {
53 return Err(ModelError::in_file(
54 &module.krate.path.join("src").join("lib.rs"),
55 format!(
56 "permissions `{earlier}` and `{permission}` both become the constant \
57 `{constant}`; rename the record or field of one"
58 ),
59 ));
60 }
61 }
62 Ok(())
63}
64
65#[must_use]
66pub fn module_permissions(module: &Module) -> Vec<String> {
67 let name = &module.krate.crate_name;
68 let mut permissions: Vec<String> = module
69 .code
70 .aggregates
71 .iter()
72 .flat_map(|aggregate| aggregate_permissions(name, aggregate))
73 .collect();
74 let routed = module.areas.iter().flat_map(|area| &area.routes);
75 permissions.extend(routed.map(|route| route_permission(module, route)));
76 permissions.sort();
77 permissions.dedup();
78 permissions
79}
80
81fn aggregate_permissions(module: &str, aggregate: &Aggregate) -> Vec<String> {
82 let model = snake_case(&aggregate.name);
83 let record = RECORD_ACTIONS
84 .iter()
85 .map(|action| format!("{module}.{model}.{WHOLE_RECORD}.{action}"));
86 let fields = aggregate.fields.iter().flat_map(|field| {
87 FIELD_ACTIONS
88 .iter()
89 .map(|action| format!("{module}.{model}.{}.{action}", field.name))
90 });
91 record.chain(fields).collect()
92}
93
94#[must_use]
97pub fn route_permission(module: &Module, route: &Route) -> String {
98 let handler = route.handler.last().map_or("", String::as_str);
99 let words: Vec<&str> = handler.split('_').filter(|word| !word.is_empty()).collect();
100 let (action, subject) = match (route.method, words.split_first()) {
101 (Method::Get, _) => ("read", words.as_slice()),
102 (_, None) => ("update", words.as_slice()),
103 (_, Some((&"read", rest))) => ("update", rest),
104 (_, Some((verb, rest))) => (*verb, rest),
105 };
106 let models: Vec<String> = module
107 .code
108 .aggregates
109 .iter()
110 .map(|aggregate| snake_case(&aggregate.name))
111 .collect();
112 let model = subject_model(&models, subject, handler);
113 format!(
114 "{}.{model}.{WHOLE_RECORD}.{action}",
115 module.krate.crate_name
116 )
117}
118
119fn subject_model(models: &[String], words: &[&str], handler: &str) -> String {
121 (1..=words.len())
122 .rev()
123 .find_map(|length| {
124 words.windows(length).find_map(|run| {
125 let joined = run.join("_");
126 models
127 .iter()
128 .find(|model| {
129 **model == joined
130 || **model == singular_run(run)
131 || sofie_core::plural(model) == joined
132 })
133 .cloned()
134 })
135 })
136 .unwrap_or_else(|| singular(words.last().copied().unwrap_or(handler)))
137}
138
139fn singular_run(run: &[&str]) -> String {
140 let mut words: Vec<String> = run.iter().map(|word| (*word).to_owned()).collect();
141 if let Some(last) = words.last_mut() {
142 *last = singular(last);
143 }
144 words.join("_")
145}
146
147fn singular(word: &str) -> String {
149 if let Some(stem) = word.strip_suffix("ies") {
150 return format!("{stem}y");
151 }
152 if ["ss", "us", "is"]
153 .iter()
154 .any(|ending| word.ends_with(ending))
155 {
156 return word.to_owned();
157 }
158 match word.strip_suffix('s') {
159 Some(stem) if !stem.is_empty() => stem.to_owned(),
160 _ => word.to_owned(),
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::{permission_constant, singular, subject_model};
167
168 #[test]
169 fn a_permission_s_constant_names_its_model_field_and_action() {
170 assert_eq!(permission_constant("order.order.*.cancel"), "ORDER_CANCEL");
171 assert_eq!(
172 permission_constant("order.order_line.total.read"),
173 "ORDER_LINE_TOTAL_READ"
174 );
175 }
176
177 #[test]
178 fn plural_record_names_become_singular() {
179 assert_eq!(singular("orders"), "order");
180 assert_eq!(singular("categories"), "category");
181 assert_eq!(singular("order"), "order");
182 assert_eq!(singular("address"), "address");
183 assert_eq!(singular("status"), "status");
184 assert_eq!(singular("campus"), "campus");
185 }
186
187 #[test]
188 fn a_list_handler_names_its_record_by_the_record_s_plural() {
189 let models = ["opening_hours", "category", "status"].map(str::to_owned);
190 let model = |handler: &str| {
191 let words: Vec<&str> = handler.split('_').collect();
192 subject_model(&models, &words[1..], handler)
193 };
194
195 assert_eq!(model("list_opening_hours"), "opening_hours");
196 assert_eq!(model("list_categories"), "category");
197 assert_eq!(model("list_statuses"), "status");
198 assert_eq!(model("list_hourses"), "hourse");
199 }
200}