Skip to main content

sofie_model/
package_routes.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use syn::visit::Visit;
5use syn::{Attribute, Expr, ExprMethodCall, GenericArgument, Item, Meta};
6
7use crate::syntax::{first_type_argument, read_file, string_literal, type_last_name, use_paths};
8use crate::{App, Code, Function, KnownArea, ModelError};
9
10const ROUTE_TABLE: &str = "ROUTES";
11/// A row naming no mount is served by the package's own `routes` function.
12pub(crate) const DEFAULT_MOUNT: &str = "routes";
13
14/// A row of a package's `ROUTES` table, kept when its features are on.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct ListedRoute {
17    pub mount: String,
18    pub method: String,
19    pub path: String,
20    pub handler: String,
21    pub permission: Option<String>,
22}
23
24/// A package's route function mounted in one of the app's areas.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct RouteMount {
27    pub mount: String,
28    /// The area type's last segment, as written.
29    pub area_type: String,
30}
31
32/// A package's route where the app serves it.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct ServedRoute<'app> {
35    pub area: String,
36    pub path: String,
37    pub name: String,
38    pub route: &'app ListedRoute,
39}
40
41/// A framework package whose routes the app mounts in its areas.
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct RoutedPackage {
44    pub crate_name: String,
45    pub routes: Vec<ListedRoute>,
46    pub mounts: Vec<RouteMount>,
47}
48
49/// The `ROUTES` tables the package's root exports, each read without compiling.
50pub(crate) fn read_route_tables(
51    directory: &Path,
52    features: &[String],
53) -> Result<Vec<ListedRoute>, ModelError> {
54    let source = directory.join("src");
55    let root = read_file(&source.join("lib.rs"))?;
56    let mut routes = Vec::new();
57    for item in &root.items {
58        let Item::Use(exported) = item else {
59            continue;
60        };
61        if !exported
62            .attrs
63            .iter()
64            .all(|attribute| enabled(attribute, features))
65        {
66            continue;
67        }
68        for used in use_paths(&exported.tree) {
69            if let [module, table] = used.segments.as_slice()
70                && table == ROUTE_TABLE
71            {
72                routes.extend(read_route_table(&module_file(&source, module), features)?);
73            }
74        }
75    }
76    Ok(routes)
77}
78
79fn module_file(source: &Path, module: &str) -> PathBuf {
80    let file = source.join(format!("{module}.rs"));
81    if file.is_file() {
82        return file;
83    }
84    source.join(module).join("mod.rs")
85}
86
87pub(crate) fn read_route_table(
88    file: &Path,
89    features: &[String],
90) -> Result<Vec<ListedRoute>, ModelError> {
91    let parsed = read_file(file)?;
92    let rows = parsed.items.iter().find_map(|item| match item {
93        Item::Const(constant) if constant.ident == ROUTE_TABLE => Some(constant.expr.as_ref()),
94        _ => None,
95    });
96    let Some(Expr::Reference(reference)) = rows else {
97        return Err(ModelError::in_file(
98            file,
99            "found no `const ROUTES: &[ListedRoute]`",
100        ));
101    };
102    let Expr::Array(array) = reference.expr.as_ref() else {
103        return Err(ModelError::in_file(
104            file,
105            "`ROUTES` is not an array literal",
106        ));
107    };
108    let mut routes = Vec::new();
109    for row in &array.elems {
110        let Expr::Call(call) = row else {
111            return Err(ModelError::in_file(file, "a `ROUTES` row is not a call"));
112        };
113        if !call
114            .attrs
115            .iter()
116            .all(|attribute| enabled(attribute, features))
117        {
118            continue;
119        }
120        let arguments: Option<Vec<String>> = call.args.iter().map(string_literal).collect();
121        let row = match arguments.as_deref() {
122            Some([method, path, handler]) => listed(DEFAULT_MOUNT, method, path, handler, None),
123            Some([mount, method, path, handler]) => listed(mount, method, path, handler, None),
124            Some([mount, method, path, handler, permission]) => {
125                listed(mount, method, path, handler, Some(permission))
126            }
127            _ => {
128                return Err(ModelError::in_file(
129                    file,
130                    "a `ROUTES` row holds other than literals",
131                ));
132            }
133        };
134        routes.push(row);
135    }
136    Ok(routes)
137}
138
139fn listed(
140    mount: &str,
141    method: &str,
142    path: &str,
143    handler: &str,
144    permission: Option<&String>,
145) -> ListedRoute {
146    ListedRoute {
147        mount: mount.to_owned(),
148        method: method.to_owned(),
149        path: path.to_owned(),
150        handler: handler.to_owned(),
151        permission: permission.cloned(),
152    }
153}
154
155/// Only `cfg` predicates over features: a package gates its routes by nothing else.
156fn enabled(attribute: &Attribute, features: &[String]) -> bool {
157    if !attribute.path().is_ident("cfg") {
158        return true;
159    }
160    attribute
161        .parse_args::<Meta>()
162        .is_ok_and(|predicate| holds(&predicate, features))
163}
164
165fn holds(predicate: &Meta, features: &[String]) -> bool {
166    match predicate {
167        Meta::NameValue(pair) if pair.path.is_ident("feature") => {
168            string_literal(&pair.value).is_some_and(|feature| features.contains(&feature))
169        }
170        Meta::List(list) => {
171            let Ok(inner) = list.parse_args_with(
172                syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
173            ) else {
174                return false;
175            };
176            let mut inner = inner.iter();
177            if list.path.is_ident("any") {
178                return inner.any(|predicate| holds(predicate, features));
179            }
180            if list.path.is_ident("all") {
181                return inner.all(|predicate| holds(predicate, features));
182            }
183            list.path.is_ident("not")
184                && inner
185                    .next()
186                    .is_some_and(|predicate| !holds(predicate, features))
187        }
188        _ => false,
189    }
190}
191
192/// `.area::<Console>("sofie_identity", sofie_identity::permitted_routes::<Console>)` in the
193/// app's own code.
194pub(crate) fn read_mounts(app: &BTreeMap<PathBuf, syn::File>, crate_name: &str) -> Vec<RouteMount> {
195    let mut found = Mounts {
196        crate_name: Some(crate_name),
197        mounts: Vec::new(),
198    };
199    for syntax in app.values() {
200        found.visit_file(syntax);
201    }
202    found.mounts
203}
204
205/// `app.area::<Web>(module, routes)` in the package's root: what its `Module` mounts itself.
206pub(crate) fn read_module_mounts(directory: &Path) -> Result<Vec<RouteMount>, ModelError> {
207    let mut found = Mounts {
208        crate_name: None,
209        mounts: Vec::new(),
210    };
211    found.visit_file(&read_file(&directory.join("src").join("lib.rs"))?);
212    Ok(found.mounts)
213}
214
215/// `crate_name` is `None` in the package's own code, which names its functions unqualified.
216struct Mounts<'name> {
217    crate_name: Option<&'name str>,
218    mounts: Vec<RouteMount>,
219}
220
221impl Mounts<'_> {
222    fn mount(&self, call: &ExprMethodCall) -> Option<RouteMount> {
223        let [module, Expr::Path(function)] = call.args.iter().collect::<Vec<_>>()[..] else {
224            return None;
225        };
226        let segments: Vec<&syn::PathSegment> = function.path.segments.iter().collect();
227        let own = match self.crate_name {
228            Some(crate_name) => {
229                let [first, rest @ ..] = segments.as_slice() else {
230                    return None;
231                };
232                if string_literal(module).as_deref() != Some(crate_name)
233                    || first.ident != crate_name
234                {
235                    return None;
236                }
237                rest
238            }
239            None => segments.as_slice(),
240        };
241        let area_type = own
242            .last()
243            .and_then(|last| type_last_name(first_type_argument(last)?))
244            .or_else(|| turbofish_type(call))?;
245        let mount = own
246            .iter()
247            .map(|segment| segment.ident.to_string())
248            .collect::<Vec<_>>()
249            .join("::");
250        Some(RouteMount { mount, area_type })
251    }
252}
253
254fn turbofish_type(call: &ExprMethodCall) -> Option<String> {
255    match call.turbofish.as_ref()?.args.first()? {
256        GenericArgument::Type(ty) => type_last_name(ty),
257        _ => None,
258    }
259}
260
261impl<'ast> Visit<'ast> for Mounts<'_> {
262    fn visit_expr_method_call(&mut self, call: &'ast ExprMethodCall) {
263        if call.method == "area"
264            && let Some(mount) = self.mount(call)
265            && !self.mounts.contains(&mount)
266        {
267            self.mounts.push(mount);
268        }
269        syn::visit::visit_expr_method_call(self, call);
270    }
271}
272
273impl App {
274    #[must_use]
275    pub fn package_routes(&self) -> PackageRoutes<'_> {
276        PackageRoutes {
277            app: self,
278            areas: self.areas(),
279        }
280    }
281}
282
283/// The framework packages' routes where the app's areas serve them.
284#[derive(Clone, Debug)]
285pub struct PackageRoutes<'app> {
286    pub(crate) app: &'app App,
287    pub(crate) areas: Vec<KnownArea<'app>>,
288}
289
290impl<'app> PackageRoutes<'app> {
291    /// Those of the packages other than sofie-identity.
292    #[must_use]
293    pub fn other_packages(&self) -> Vec<ServedRoute<'app>> {
294        self.app
295            .packages
296            .iter()
297            .flat_map(|package| self.mounted(&package.crate_name, &package.routes, &package.mounts))
298            .collect()
299    }
300
301    pub(crate) fn mounted(
302        &self,
303        crate_name: &str,
304        routes: &'app [ListedRoute],
305        mounts: impl IntoIterator<Item = &'app RouteMount>,
306    ) -> Vec<ServedRoute<'app>> {
307        let mut served = Vec::new();
308        for mount in mounts {
309            let Some(area) = self
310                .areas
311                .iter()
312                .find(|area| area.type_path.rsplit("::").next() == Some(mount.area_type.as_str()))
313            else {
314                continue;
315            };
316            let Some(prefix) = area.prefix() else {
317                continue;
318            };
319            let mounted = routes.iter().filter(|route| route.mount == mount.mount);
320            for route in mounted {
321                let path = format!("{prefix}{}", route.path);
322                served.push(served_route(crate_name, area.name, area.name, path, route));
323            }
324        }
325        served
326    }
327}
328
329/// Named `<crate>.<area>.<handler>`; a member route is named per member area it signs in to,
330/// so each area's names stay unique.
331pub(crate) fn served_route<'app>(
332    crate_name: &str,
333    area: &str,
334    named_for: &str,
335    path: String,
336    route: &'app ListedRoute,
337) -> ServedRoute<'app> {
338    ServedRoute {
339        area: area.to_owned(),
340        path,
341        name: format!("{crate_name}.{named_for}.{}", route.handler),
342        route,
343    }
344}
345
346impl ServedRoute<'_> {
347    /// The crate serving it, which its name starts with.
348    #[must_use]
349    pub fn package(&self) -> &str {
350        self.name.split('.').next().unwrap_or_default()
351    }
352}
353
354impl ListedRoute {
355    #[must_use]
356    pub fn function<'code>(&self, code: &'code Code) -> Option<&'code Function> {
357        code.functions
358            .iter()
359            .find(|function| function.path.last() == Some(&self.handler))
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use std::collections::BTreeMap;
366    use std::path::{Path, PathBuf};
367
368    use super::{read_module_mounts, read_mounts, read_route_table, read_route_tables};
369    use crate::testing::TemporaryDirectory;
370
371    fn kept_by(
372        features: &[&str],
373        read: impl Fn(&[String]) -> Vec<super::ListedRoute>,
374    ) -> Vec<String> {
375        let features: Vec<String> = features.iter().map(|&name| name.to_owned()).collect();
376        read(&features)
377            .into_iter()
378            .map(|route| format!("{} {}", route.mount, route.handler))
379            .collect()
380    }
381
382    #[test]
383    fn keeps_the_rows_whose_features_are_on() {
384        let directory = TemporaryDirectory::new("route-table");
385        let file = directory.write(
386            "route_table.rs",
387            "pub const ROUTES: &[ListedRoute] = &[\n\
388             member(\"GET\", \"{sign_in}\", \"sign_in_page\"),\n\
389             #[cfg(any(feature = \"password-reset\", feature = \"email-verification\"))]\n\
390             member(\"GET\", \"{prefix}/link-sent\", \"link_sent_page\"),\n\
391             #[cfg(feature = \"token-screens\")]\n\
392             permitted(\"tokens::screen::routes\", \"GET\", \"/tokens\", \"list_tokens\", \"t.*.read\"),\n\
393             ];\n",
394        );
395        let read = |features: &[String]| read_route_table(&file, features).unwrap();
396
397        assert_eq!(kept_by(&[], read), ["routes sign_in_page"]);
398        assert_eq!(
399            kept_by(&["email-verification", "token-screens"], read),
400            [
401                "routes sign_in_page",
402                "routes link_sent_page",
403                "tokens::screen::routes list_tokens"
404            ]
405        );
406    }
407
408    #[test]
409    fn reads_every_table_the_root_exports_while_its_features_are_on() {
410        let directory = TemporaryDirectory::new("route-tables");
411        directory.write(
412            "src/lib.rs",
413            "mod public;\n#[cfg(feature = \"admin\")]\nmod admin;\n\n\
414             pub use public::{ROUTES, routes};\n\
415             #[cfg(feature = \"admin\")]\npub use admin::ROUTES as ADMIN_ROUTES;\n",
416        );
417        directory.write(
418            "src/public.rs",
419            "pub const ROUTES: &[ListedRoute] = &[listed(\"GET\", \"/pages/{slug}\", \"page\")];\n",
420        );
421        directory.write(
422            "src/admin/mod.rs",
423            "pub const ROUTES: &[ListedRoute] = &[\n\
424             listed(\"admin_routes\", \"POST\", \"/pages/{page}/publish\", \"publish\"),\n];\n",
425        );
426        let read = |features: &[String]| read_route_tables(directory.root(), features).unwrap();
427
428        assert_eq!(kept_by(&[], read), ["routes page"]);
429        assert_eq!(
430            kept_by(&["admin"], read),
431            ["routes page", "admin_routes publish"]
432        );
433    }
434
435    #[test]
436    fn the_framework_packages_list_their_routes() {
437        let package = |name: &str| Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(name);
438        let handlers = |name: &str, features: &[&str]| {
439            kept_by(features, |features| {
440                read_route_tables(&package(name), features).unwrap()
441            })
442        };
443
444        assert!(handlers("sofie-identity", &[]).contains(&"routes sign_in_page".to_owned()));
445        assert_eq!(
446            handlers("sofie-tenancy", &[]),
447            [
448                "organization_routes list_members",
449                "organization_routes invite_member",
450                "organization_routes make_owner",
451                "organization_routes make_admin",
452                "organization_routes make_member",
453                "organization_routes withdraw_invitation",
454                "organization_routes remove_member",
455                "organization_routes hand_over_organization",
456                "organization_routes leave_organization",
457                "organization_routes rename_organization",
458                "organization_routes delete_organization",
459                "member_routes list_organizations",
460                "member_routes found_organization",
461                "member_routes enter_organization",
462                "member_routes invitation_page",
463                "member_routes accept_invitation",
464            ]
465        );
466        assert_eq!(
467            handlers("sofie-content", &[]),
468            ["routes page", "routes sitemap"]
469        );
470        assert_eq!(handlers("sofie-content", &["admin"]).len(), 10);
471        assert_eq!(
472            read_module_mounts(&package("sofie-content"))
473                .unwrap()
474                .into_iter()
475                .map(|mount| (mount.mount, mount.area_type))
476                .collect::<Vec<_>>(),
477            [("routes".to_owned(), "Web".to_owned())]
478        );
479    }
480
481    #[test]
482    fn finds_the_routes_the_app_mounts_from_a_package() {
483        let main = syn::parse_str(
484            "fn app(db: Db) -> App {\n    wiring::app(db)\n        \
485             .area::<Console>(\"sofie_identity\", sofie_identity::permitted_routes::<Console>)\n        \
486             .area::<Web>(\"sofie_content\", sofie_content::routes)\n        \
487             .area(\"sofie_tenancy\", sofie_tenancy::member_routes::<Member, Staff>)\n        \
488             .area::<Console>(\"shop\", shop::routes)\n}\n",
489        )
490        .unwrap();
491        let app = BTreeMap::from([(PathBuf::from("src/main.rs"), main)]);
492        let mounts = |crate_name: &str| -> Vec<(String, String)> {
493            read_mounts(&app, crate_name)
494                .into_iter()
495                .map(|mount| (mount.mount, mount.area_type))
496                .collect()
497        };
498        let pair = |mount: &str, area: &str| (mount.to_owned(), area.to_owned());
499
500        assert_eq!(
501            mounts("sofie_identity"),
502            [pair("permitted_routes", "Console")]
503        );
504        assert_eq!(mounts("sofie_content"), [pair("routes", "Web")]);
505        assert_eq!(mounts("sofie_tenancy"), [pair("member_routes", "Member")]);
506    }
507}