Skip to main content

sofie_cli/
routes_command.rs

1use sofie_model::naming::RouteSite;
2use sofie_model::{App, ModelError, Modifier, Route, ServedRoute, Then};
3
4const HEADERS: [&str; 9] = [
5    "AREA",
6    "METHOD",
7    "PATH",
8    "NAME",
9    "HANDLER",
10    "PERMISSION",
11    "FEATURE",
12    "OWNER SCOPE",
13    "MODIFIERS",
14];
15
16pub(crate) const GENERATOR_PATHS: &str = "Route paths in `routes` and `explain` are full, with \
17     the area's prefix; generators such as `sofie new route` take a path relative to the area's \
18     prefix: `/orders/{order}` for `/account/orders/{order}` in an area under `/account`.";
19
20/// The framework packages' routes are part of the table, so failing to read them fails it.
21pub fn route_table(app: &App) -> Result<String, ModelError> {
22    let mut sites = app.route_sites();
23    sites.sort_by(|left, right| {
24        (&left.area.area, &left.route.path, left.route.method).cmp(&(
25            &right.area.area,
26            &right.route.path,
27            right.route.method,
28        ))
29    });
30    let mut rows: Vec<[String; 9]> = sites.iter().map(|site| row(app, site)).collect();
31    let package_routes = app.package_routes();
32    let packages = package_routes
33        .identity()?
34        .into_iter()
35        .chain(package_routes.other_packages());
36    rows.extend(packages.map(|served| package_row(app, &served)));
37    Ok(format!(
38        "{}\n{GENERATOR_PATHS}\n",
39        text_table(HEADERS, &rows)
40    ))
41}
42
43fn package_row(app: &App, served: &ServedRoute) -> [String; 9] {
44    [
45        served.area.clone(),
46        served.route.method.clone(),
47        served.path.clone(),
48        served.name.clone(),
49        format!("{}::{}", served.package(), served.route.mount),
50        served.route.permission.clone().unwrap_or_default(),
51        String::new(),
52        owner_scope(app, &served.area).to_owned(),
53        String::new(),
54    ]
55}
56
57fn row(app: &App, site: &RouteSite) -> [String; 9] {
58    let route = site.route;
59    [
60        site.area.area.clone(),
61        route.method.name().to_owned(),
62        shown_path(app, site),
63        site.name(),
64        handler_path(site),
65        site.permission(app).unwrap_or_default(),
66        feature(site).unwrap_or_default(),
67        owner_scope(app, &site.area.area).to_owned(),
68        modifiers(route).join(" "),
69    ]
70}
71
72pub(crate) fn feature(site: &RouteSite) -> Option<String> {
73    let declaration = site.module.route_feature(site.route)?;
74    Some(
75        declaration
76            .name
77            .clone()
78            .unwrap_or_else(|| declaration.constant.join("::")),
79    )
80}
81
82pub(crate) fn handler_path(site: &RouteSite) -> String {
83    format!(
84        "{}::{}",
85        site.module.krate.crate_name,
86        site.route.handler.join("::")
87    )
88}
89
90pub(crate) fn owner_scope(app: &App, area: &str) -> &'static str {
91    match app.area(area) {
92        Some(known) if known.is_public() || known.is_permitted() => "any",
93        Some(_) => "principal's own",
94        None => "unknown area",
95    }
96}
97
98pub(crate) fn modifiers(route: &Route) -> Vec<String> {
99    let mut written = Vec::new();
100    written.extend(
101        route
102            .screen
103            .as_ref()
104            .map(|screen| format!("screen({screen})")),
105    );
106    written.extend(
107        route
108            .webhook
109            .as_ref()
110            .map(|verifier| format!("webhook verify({verifier})")),
111    );
112    written.extend(
113        route
114            .modifiers
115            .iter()
116            .filter_map(|modifier| match modifier {
117                Modifier::Throttle(rate) => Some(format!("throttle({rate})")),
118                Modifier::Signed => Some("signed".to_owned()),
119                Modifier::Idempotent => Some("idempotent".to_owned()),
120                Modifier::BodyLimit(bytes) => Some(format!("body_limit({bytes})")),
121                Modifier::Live(fragment) => Some(format!("live({fragment})")),
122                Modifier::Cached(time) => Some(format!("cached({time})")),
123                Modifier::Queued => Some("queued".to_owned()),
124                Modifier::Entitled(entitlement) => Some(format!("entitled({entitlement})")),
125                Modifier::Document(media) => Some(format!("document({media})")),
126                Modifier::FileLimit(bytes) => Some(format!("file_limit({bytes})")),
127                Modifier::Feature(_) => None,
128            }),
129    );
130    written.extend(route.then.as_ref().map(|then| match then {
131        Then::Screen { screen, fragment } => format!("then_screen({screen}#{fragment})"),
132        Then::Redirect(path) => format!("then_redirect({path})"),
133        Then::Follow => "then_follow".to_owned(),
134        Then::RedirectOrFollow(path) => format!("then_redirect_or_follow({path})"),
135    }));
136    written
137}
138
139/// Its own path when its area's prefix is not a literal.
140pub(crate) fn shown_path(app: &App, site: &RouteSite) -> String {
141    site.full_path(app)
142        .unwrap_or_else(|| site.route.path.clone())
143}
144
145pub(crate) fn text_table<const COLUMNS: usize>(
146    headers: [&str; COLUMNS],
147    rows: &[[String; COLUMNS]],
148) -> String {
149    let mut widths = headers.map(str::len);
150    for row in rows {
151        for (width, cell) in widths.iter_mut().zip(row) {
152            *width = (*width).max(cell.chars().count());
153        }
154    }
155    let mut table = line(&headers.map(str::to_owned), &widths);
156    for row in rows {
157        table.push_str(&line(row, &widths));
158    }
159    table
160}
161
162fn line(cells: &[String], widths: &[usize]) -> String {
163    let padded: Vec<String> = cells
164        .iter()
165        .zip(widths)
166        .map(|(cell, width)| format!("{cell:<width$}"))
167        .collect();
168    let mut text = padded.join("  ").trim_end().to_owned();
169    text.push('\n');
170    text
171}
172
173#[cfg(test)]
174mod tests {
175    use std::path::PathBuf;
176
177    use sofie_model::testing::{app, area, define_area, module, route};
178    use sofie_model::{
179        FeatureDeclaration, Identity, ListedRoute, Method, Modifier, RouteMount, RoutedPackage,
180    };
181
182    use super::{GENERATOR_PATHS, route_table};
183
184    #[test]
185    fn shows_the_permission_a_permitted_area_needs_and_the_flag_a_route_hides_behind() {
186        let mut order = module("order", "shop/modules/order");
187        let mut cancel = route(
188            Method::Post,
189            "/orders/{order}/cancel",
190            "actions::cancel_order",
191            4,
192        );
193        cancel
194            .modifiers
195            .push(Modifier::Feature("crate::module::SPLIT".to_owned()));
196        order.areas.push(area(&order, "console", vec![cancel]));
197        define_area(&mut order, "console");
198        order.defined_areas[0].principal = vec!["Permitted".to_owned()];
199        order.code.features.declarations = vec![FeatureDeclaration {
200            constant: vec!["module".to_owned(), "SPLIT".to_owned()],
201            name: Some("order.split".to_owned()),
202            added: Some("2026-09-01".to_owned()),
203            retire_after_days: 30,
204            variants: false,
205            file: PathBuf::from("shop/modules/order/src/module.rs"),
206            line: 5,
207        }];
208
209        let table = route_table(&app("shop", vec![order])).unwrap();
210
211        let row: Vec<&str> = table.lines().nth(1).unwrap().split_whitespace().collect();
212        assert_eq!(
213            row,
214            [
215                "console",
216                "POST",
217                "/account/orders/{order}/cancel",
218                "order.console.cancel_order",
219                "order::actions::cancel_order",
220                "order.order.*.cancel",
221                "order.split",
222                "any"
223            ],
224            "{table}"
225        );
226        assert!(
227            table.ends_with(&format!("\n\n{GENERATOR_PATHS}\n")),
228            "{table}"
229        );
230    }
231
232    #[test]
233    fn lists_the_identity_routes_the_app_mounts_in_its_areas() {
234        let mut order = module("order", "shop/modules/order");
235        define_area(&mut order, "console");
236        order.defined_areas[0].type_path = vec!["order_contract".to_owned(), "Console".to_owned()];
237        let mut shop = app("shop", vec![order]);
238        shop.identity = Some(Identity {
239            routes: vec![ListedRoute {
240                mount: "permitted_routes".to_owned(),
241                method: "GET".to_owned(),
242                path: "/roles".to_owned(),
243                handler: "list_roles".to_owned(),
244                permission: Some("sofie_identity.role.*.read".to_owned()),
245            }],
246            mounts: vec![RouteMount {
247                mount: "permitted_routes".to_owned(),
248                area_type: "Console".to_owned(),
249            }],
250            ..Identity::default()
251        });
252
253        let table = route_table(&shop).unwrap();
254
255        let row: Vec<&str> = table.lines().nth(1).unwrap().split_whitespace().collect();
256        assert_eq!(
257            row,
258            [
259                "console",
260                "GET",
261                "/account/roles",
262                "sofie_identity.console.list_roles",
263                "sofie_identity::permitted_routes",
264                "sofie_identity.role.*.read",
265                "principal's",
266                "own"
267            ],
268            "{table}"
269        );
270    }
271
272    #[test]
273    fn lists_the_routes_of_the_packages_the_app_mounts() {
274        let mut order = module("order", "shop/modules/order");
275        define_area(&mut order, "staff");
276        order.defined_areas[0].type_path = vec!["order_contract".to_owned(), "Staff".to_owned()];
277        let mut shop = app("shop", vec![order]);
278        let listed = |mount: &str, path: &str, handler: &str| ListedRoute {
279            mount: mount.to_owned(),
280            method: "GET".to_owned(),
281            path: path.to_owned(),
282            handler: handler.to_owned(),
283            permission: None,
284        };
285        let mounted = |mount: &str, area_type: &str| RouteMount {
286            mount: mount.to_owned(),
287            area_type: area_type.to_owned(),
288        };
289        shop.packages = vec![
290            RoutedPackage {
291                crate_name: "sofie_content".to_owned(),
292                routes: vec![
293                    listed("routes", "/sitemap.xml", "sitemap"),
294                    listed("admin_routes", "/pages", "list_pages"),
295                ],
296                mounts: vec![mounted("routes", "Web")],
297            },
298            RoutedPackage {
299                crate_name: "sofie_tenancy".to_owned(),
300                routes: vec![listed("organization_routes", "/members", "list_members")],
301                mounts: vec![mounted("organization_routes", "Staff")],
302            },
303        ];
304
305        let table = route_table(&shop).unwrap();
306
307        let rows: Vec<Vec<&str>> = table
308            .lines()
309            .skip(1)
310            .take(2)
311            .map(|line| line.split_whitespace().collect())
312            .collect();
313        assert_eq!(
314            rows,
315            [
316                vec![
317                    "web",
318                    "GET",
319                    "/sitemap.xml",
320                    "sofie_content.web.sitemap",
321                    "sofie_content::routes",
322                    "any"
323                ],
324                vec![
325                    "staff",
326                    "GET",
327                    "/account/members",
328                    "sofie_tenancy.staff.list_members",
329                    "sofie_tenancy::organization_routes",
330                    "principal's",
331                    "own"
332                ],
333            ],
334            "{table}"
335        );
336        assert!(!table.contains("list_pages"), "{table}");
337    }
338
339    #[test]
340    fn identity_routes_it_cannot_read_fail_the_table() {
341        let mut order = module("order", "shop/modules/order");
342        define_area(&mut order, "console");
343        let mut shop = app("shop", vec![order]);
344        shop.identity = Some(Identity {
345            routes: vec![ListedRoute {
346                mount: "routes".to_owned(),
347                method: "GET".to_owned(),
348                path: "/profile".to_owned(),
349                handler: "profile".to_owned(),
350                permission: None,
351            }],
352            ..Identity::default()
353        });
354
355        let error = route_table(&shop).unwrap_err();
356
357        assert!(error.message.contains("cannot read the file"), "{error}");
358    }
359}