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
20pub 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
139pub(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}