sofie_http/gate/optional/
menu.rs1use std::collections::HashMap;
4use std::marker::PhantomData;
5use std::sync::Arc;
6
7use sofie_core::{Error, Result};
8use sofie_engine::push_escaped;
9
10use crate::gate::RequestGates;
11use crate::routes::Audience;
12use crate::{App, Area, RoutePath};
13
14pub struct Menu<A: Area> {
15 entries: Vec<Entry>,
16 area: PhantomData<fn() -> A>,
17}
18
19pub struct MenuEntry<'m> {
20 #[cfg_attr(
21 not(any(feature = "flags", feature = "entitlements")),
22 expect(dead_code, reason = "only flags and entitlements modify it")
23 )]
24 entry: &'m mut Entry,
25}
26
27#[derive(Clone)]
28struct Entry {
29 label: String,
30 route: RoutePath,
31 #[cfg(feature = "flags")]
32 features: Vec<sofie_flags::Feature>,
33 #[cfg(feature = "permissions")]
34 permission: Option<crate::Permission>,
35 #[cfg(feature = "entitlements")]
36 entitlements: Vec<crate::Entitlement>,
37}
38
39#[derive(Default)]
40pub(crate) struct Menus(HashMap<&'static str, Vec<Entry>>);
41
42impl<A: Area> Menu<A> {
43 pub fn entry(&mut self, label: &str, route: RoutePath) -> MenuEntry<'_> {
45 let entry = self.entries.push_mut(Entry {
46 label: label.to_owned(),
47 route,
48 #[cfg(feature = "flags")]
49 features: Vec::new(),
50 #[cfg(feature = "permissions")]
51 permission: None,
52 #[cfg(feature = "entitlements")]
53 entitlements: Vec::new(),
54 });
55 MenuEntry { entry }
56 }
57}
58
59#[cfg(feature = "flags")]
60impl MenuEntry<'_> {
61 pub fn feature(&mut self, feature: sofie_flags::Feature) -> &mut Self {
62 self.entry.features.push(feature);
63 self
64 }
65}
66
67#[cfg(feature = "entitlements")]
68impl MenuEntry<'_> {
69 pub fn entitled(&mut self, entitlement: crate::Entitlement) -> &mut Self {
70 self.entry.entitlements.push(entitlement);
71 self
72 }
73}
74
75pub trait Navigation {
76 #[must_use]
77 fn menu<A: Area>(self, define: impl FnOnce(&mut Menu<A>)) -> Self;
78}
79
80impl Navigation for App {
81 fn menu<A: Area>(mut self, define: impl FnOnce(&mut Menu<A>)) -> Self {
82 let mut menu = Menu {
83 entries: Vec::new(),
84 area: PhantomData,
85 };
86 define(&mut menu);
87 let menus = Arc::get_mut(&mut self.gates.menus)
88 .expect("menus are added while the app is built, before any request holds them");
89 menus.0.entry(A::NAME).or_default().extend(menu.entries);
90 self
91 }
92}
93
94pub(crate) fn prepare(app: &mut App) -> Result<()> {
96 let menus = Arc::get_mut(&mut app.gates.menus).expect("no request holds the menus yet");
97 for (area, entries) in &mut menus.0 {
98 for entry in entries {
99 let route = app
100 .routes
101 .iter()
102 .find(|route| {
103 route.audience.name == *area
104 && route.method == entry.route.method()
105 && route.path == entry.route.path()
106 })
107 .ok_or_else(|| {
108 Error::internal_message(format!(
109 "the menu of area `{area}` links to {} {}, which no page of the area \
110 without parameters answers",
111 entry.route.method(),
112 entry.route.path()
113 ))
114 })?;
115 #[cfg(feature = "flags")]
116 entry.features.extend(route.rules.feature);
117 #[cfg(feature = "entitlements")]
118 entry.entitlements.extend(route.rules.entitlement);
119 #[cfg(feature = "permissions")]
120 {
121 entry.permission = route
122 .rules
123 .permission
124 .filter(|_| route.audience.access == crate::Access::Permitted);
125 }
126 let _ = route;
127 }
128 }
129 Ok(())
130}
131
132impl Menus {
133 pub(crate) fn navigation(
134 &self,
135 area: &Audience,
136 current: &str,
137 gates: &RequestGates,
138 ) -> String {
139 let Some(entries) = self.0.get(area.name) else {
140 return String::new();
141 };
142 let mut html = String::from("<nav class=\"sofie-nav\"><ul>");
143 for entry in entries.iter().filter(|entry| entry.may_follow(area, gates)) {
144 html.push_str("<li><a href=\"");
145 push_escaped(&mut html, entry.route.path());
146 html.push('"');
147 if entry.route.path() == current {
148 html.push_str(" aria-current=\"page\"");
149 }
150 html.push('>');
151 push_escaped(&mut html, &entry.label);
152 html.push_str("</a></li>");
153 }
154 html.push_str("</ul></nav>");
155 html
156 }
157}
158
159#[cfg(feature = "clients")]
160impl Menus {
161 pub(crate) fn entries(
162 &self,
163 area: &Audience,
164 current: &str,
165 gates: &RequestGates,
166 ) -> serde_json::Value {
167 let entries = self.0.get(area.name).map_or(&[][..], Vec::as_slice);
168 entries
169 .iter()
170 .filter(|entry| entry.may_follow(area, gates))
171 .map(|entry| {
172 serde_json::json!({
173 "label": entry.label,
174 "path": entry.route.path(),
175 "current": entry.route.path() == current,
176 })
177 })
178 .collect()
179 }
180}
181
182impl Entry {
183 #[cfg_attr(
184 not(any(feature = "flags", feature = "permissions", feature = "entitlements")),
185 expect(clippy::unused_self, reason = "only gates hide an entry")
186 )]
187 fn may_follow(&self, area: &Audience, gates: &RequestGates) -> bool {
188 let _ = (area, gates);
189 #[cfg(feature = "flags")]
190 if !self.features.iter().all(|feature| {
191 gates
192 .features
193 .as_ref()
194 .is_some_and(|features| features.enabled(*feature))
195 }) {
196 return false;
197 }
198 #[cfg(feature = "entitlements")]
199 if !self
200 .entitlements
201 .iter()
202 .all(|entitlement| gates.entitled.contains(&entitlement.name()))
203 {
204 return false;
205 }
206 #[cfg(feature = "permissions")]
207 if area.access == crate::Access::Permitted
208 && !self
209 .permission
210 .is_some_and(|permission| gates.permissions.can(permission))
211 {
212 return false;
213 }
214 true
215 }
216}
217
218#[cfg(all(test, feature = "flags"))]
219mod tests {
220 use super::*;
221 use crate::{Method, Routes, Web};
222
223 async fn page() -> Result<()> {
224 Ok(())
225 }
226
227 #[test]
228 fn an_entry_behind_a_feature_is_hidden_from_an_app_without_flags() {
229 let mut routes = Routes::<Web>::new("shop");
230 routes.get("/beta", page);
231 let mut menu = Menu::<Web> {
232 entries: Vec::new(),
233 area: PhantomData,
234 };
235 menu.entry("Beta", RoutePath::new(Method::GET, "/beta".to_owned()))
236 .feature(sofie_flags::Feature::new("shop.beta"));
237 let menus = Menus(HashMap::from([(Web::NAME, menu.entries)]));
238
239 let shown = menus.navigation(&routes.added[0].audience, "/", &RequestGates::default());
240
241 assert!(!shown.contains("Beta"), "{shown}");
242 }
243}
244
245#[cfg(test)]
246mod ordering {
247 use super::*;
248 use crate::{Gates, Method, Web};
249
250 #[test]
251 fn gates_given_after_a_menu_keep_it() {
252 let app = App::new(sofie_db::Db::new(sofie_testing::FakeDriver::default()))
253 .menu::<Web>(|menu| {
254 menu.entry("Home", RoutePath::new(Method::GET, "/".to_owned()));
255 })
256 .gates(Gates::default());
257
258 assert_eq!(app.gates.menus.0[Web::NAME].len(), 1);
259 }
260}