Skip to main content

sofie_http/gate/optional/
menu.rs

1//! An entry is hidden from a viewer who could not follow it.
2
3use 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    /// `route` must be a page of this area without parameters.
44    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
94/// An entry no route of its area answers stops the app.
95pub(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}