Skip to main content

sofie_model/
routes.rs

1use std::path::Path;
2
3use syn::{Expr, ExprMethodCall, Pat, Stmt};
4
5use crate::handlers::HandlerScope;
6use crate::naming::{is_plain_name, snake_case};
7use crate::syntax::{
8    builder_function, last_name, line_of, segments, single_argument, string_literal,
9};
10use crate::{Method, ModelError, Modifier, Route, Then};
11
12const SIGNATURE: &str = "`pub fn routes(routes: &mut Routes<Area>)`";
13
14pub(crate) fn read_routes(
15    file: &Path,
16    syntax: &syn::File,
17    module_path: Vec<String>,
18    deletable: &[String],
19) -> Result<Vec<Route>, ModelError> {
20    let (function, receiver) = builder_function(file, &syntax.items, "routes", SIGNATURE)?;
21    let scope = HandlerScope::new(module_path, &syntax.items);
22    let mut routes = Vec::new();
23    for statement in &function.block.stmts {
24        routes.extend(routes_of(file, &receiver, statement, "", deletable)?);
25    }
26    for route in &mut routes {
27        check_route_path(&route.path)
28            .map_err(|message| ModelError::new(file, route.line, message))?;
29        if !route.is_resource() {
30            route.handler = scope
31                .resolve(&route.handler)
32                .map_err(|message| ModelError::new(file, route.line, message))?;
33        }
34    }
35    Ok(routes)
36}
37
38const RESOURCE_ROOT: &str = "sofie";
39
40impl Route {
41    #[must_use]
42    pub fn is_resource(&self) -> bool {
43        self.handler.first().map(String::as_str) == Some(RESOURCE_ROOT)
44    }
45}
46
47fn routes_of(
48    file: &Path,
49    receiver: &str,
50    statement: &Stmt,
51    prefix: &str,
52    deletable: &[String],
53) -> Result<Vec<Route>, ModelError> {
54    let line = line_of(statement);
55    let error = |message: String| ModelError::new(file, line, message);
56    let Stmt::Expr(Expr::MethodCall(outermost), _) = statement else {
57        return Err(error(statements_message(receiver)));
58    };
59    let (base, modifiers) =
60        call_chain(outermost, receiver).ok_or_else(|| error(statements_message(receiver)))?;
61    match base.method.to_string().as_str() {
62        "group" if modifiers.is_empty() => group_of(file, base, prefix, deletable),
63        "resource" => resource_of(base, &modifiers, prefix, line, deletable).map_err(error),
64        _ => {
65            let mut route = route_of(base, &modifiers, line).map_err(error)?;
66            route.path.insert_str(0, prefix);
67            Ok(vec![route])
68        }
69    }
70}
71
72fn group_of(
73    file: &Path,
74    call: &ExprMethodCall,
75    prefix: &str,
76    deletable: &[String],
77) -> Result<Vec<Route>, ModelError> {
78    let line = line_of(call);
79    let (Some(path), Some(Expr::Closure(closure)), 2) =
80        (call.args.first(), call.args.get(1), call.args.len())
81    else {
82        return Err(ModelError::new(file, line, GROUP_SHAPE.to_owned()));
83    };
84    let path = path_of(path).map_err(|message| ModelError::new(file, line, message))?;
85    let (Some(Pat::Ident(receiver)), 1, Expr::Block(body)) = (
86        closure.inputs.first(),
87        closure.inputs.len(),
88        closure.body.as_ref(),
89    ) else {
90        return Err(ModelError::new(file, line, GROUP_SHAPE.to_owned()));
91    };
92    let receiver = receiver.ident.to_string();
93    let prefix = format!("{prefix}{path}");
94    let mut routes = Vec::new();
95    for statement in &body.block.stmts {
96        routes.extend(routes_of(file, &receiver, statement, &prefix, deletable)?);
97    }
98    Ok(routes)
99}
100
101const GROUP_SHAPE: &str = "write the group as `.group(\"/path\", |routes| { … })`: a literal \
102     path and a closure of route statements";
103
104const RESOURCE_SCREENS: [&str; 5] = ["Index", "Show", "Create", "Edit", "Delete"];
105
106fn resource_of(
107    call: &ExprMethodCall,
108    modifiers: &[&ExprMethodCall],
109    prefix: &str,
110    line: usize,
111    deletable: &[String],
112) -> Result<Vec<Route>, String> {
113    let aggregate = resource_type(call)?;
114    let name = snake_case(&aggregate);
115    let kept = match modifiers {
116        // sofie-http deletes softly only, so by default only a soft-deleting aggregate's records.
117        [] => RESOURCE_SCREENS
118            .into_iter()
119            .filter(|screen| *screen != "Delete" || deletable.contains(&name))
120            .map(str::to_owned)
121            .collect(),
122        [only] if only.method == "only" => kept_screens(only)?,
123        _ => return Err("a resource takes only `.only([Index, Show, …])`".to_owned()),
124    };
125    let plural = sofie_core::plural(&name);
126    let at = format!("{prefix}/{}", plural.replace('_', "-"));
127    let record = format!("{at}/{{{name}}}");
128    let route = |method, path: String, handler: String, screen: Option<&str>| Route {
129        screen: screen.map(|screen| format!("{name}_{screen}")),
130        ..new_route(
131            method,
132            path,
133            vec![RESOURCE_ROOT.to_owned(), "resource".to_owned(), handler],
134            line,
135        )
136    };
137    let routes_of = |screen: &str| match screen {
138        "Index" => vec![route(
139            Method::Get,
140            at.clone(),
141            format!("list_{plural}"),
142            Some("index"),
143        )],
144        "Show" => vec![route(
145            Method::Get,
146            record.clone(),
147            format!("show_{name}"),
148            Some("show"),
149        )],
150        "Create" => vec![
151            route(
152                Method::Get,
153                format!("{at}/new"),
154                format!("new_{name}"),
155                Some("create"),
156            ),
157            route(
158                Method::Post,
159                format!("{at}/new"),
160                format!("create_{name}"),
161                None,
162            ),
163        ],
164        "Edit" => vec![
165            route(
166                Method::Get,
167                format!("{record}/edit"),
168                format!("edit_{name}"),
169                Some("edit"),
170            ),
171            route(
172                Method::Post,
173                format!("{record}/edit"),
174                format!("update_{name}"),
175                None,
176            ),
177        ],
178        _ => vec![route(
179            Method::Post,
180            format!("{record}/delete"),
181            format!("delete_{name}"),
182            None,
183        )],
184    };
185    Ok(kept.iter().flat_map(|screen| routes_of(screen)).collect())
186}
187
188fn resource_type(call: &ExprMethodCall) -> Result<String, String> {
189    let shape = || "write the resource as `.resource::<Aggregate>()`".to_owned();
190    let Some(turbofish) = &call.turbofish else {
191        return Err(shape());
192    };
193    match (
194        turbofish.args.first(),
195        turbofish.args.len(),
196        call.args.is_empty(),
197    ) {
198        (Some(syn::GenericArgument::Type(syn::Type::Path(path))), 1, true) => {
199            last_name(&path.path).ok_or_else(shape)
200        }
201        _ => Err(shape()),
202    }
203}
204
205fn kept_screens(only: &ExprMethodCall) -> Result<Vec<String>, String> {
206    let shape = || {
207        format!(
208            "write `.only([…])` with a list of {}",
209            RESOURCE_SCREENS.join(", ")
210        )
211    };
212    let (Some(Expr::Array(list)), 1) = (only.args.first(), only.args.len()) else {
213        return Err(shape());
214    };
215    list.elems
216        .iter()
217        .map(|element| match element {
218            Expr::Path(path) => last_name(&path.path)
219                .filter(|name| RESOURCE_SCREENS.contains(&name.as_str()))
220                .ok_or_else(shape),
221            _ => Err(shape()),
222        })
223        .collect()
224}
225
226fn route_of(
227    base: &ExprMethodCall,
228    modifiers: &[&ExprMethodCall],
229    line: usize,
230) -> Result<Route, String> {
231    let mut route = route_call(base, line)?;
232    for (index, call) in modifiers.iter().enumerate().rev() {
233        apply_modifier(&mut route, call, index == 0)?;
234    }
235    if route.webhook.as_deref() == Some("") {
236        return Err(
237            "a webhook is served only once `.verify(Verifier)` names how its deliveries are \
238             verified; end the statement with it"
239                .to_owned(),
240        );
241    }
242    Ok(route)
243}
244
245fn statements_message(receiver: &str) -> String {
246    format!(
247        "`sofie gen` reads routes without compiling, so this body may only hold \
248         `{receiver}.get|post|put|patch|delete(\"/path\", handler)`, \
249         `{receiver}.screen(\"/path\", \"name\", loader)`, \
250         `{receiver}.island(\"/path\", \"name\", loader)` and \
251         `{receiver}.webhook(\"/path\", handler).verify(Verifier)`, \
252         `{receiver}.queue(\"/path\", job)`, \
253         `{receiver}.resource::<Aggregate>()` and `{receiver}.group(\"/path\", |routes| {{ … }})` \
254         statements, followed by \
255         `.throttle(…)`, `.signed()`, `.feature(…)`, `.entitled(…)`, `.idempotent()`, \
256         `.body_limit(…)` and an action's `.then_screen(\"name\", \"fragment\")`, \
257         `.then_redirect(\"/path\")` or `.then_follow()`; move other \
258         code into a handler"
259    )
260}
261
262/// The call on `receiver` and the modifiers chained on it, outermost first.
263fn call_chain<'a>(
264    outermost: &'a ExprMethodCall,
265    receiver: &str,
266) -> Option<(&'a ExprMethodCall, Vec<&'a ExprMethodCall>)> {
267    let mut modifiers = Vec::new();
268    let mut call = outermost;
269    while !is_receiver(&call.receiver, receiver) {
270        let Expr::MethodCall(inner) = call.receiver.as_ref() else {
271            return None;
272        };
273        modifiers.push(call);
274        call = inner;
275    }
276    Some((call, modifiers))
277}
278
279fn route_call(call: &ExprMethodCall, line: usize) -> Result<Route, String> {
280    match call.method.to_string().as_str() {
281        "screen" => return screen_of(call, line),
282        "island" => return crate::islands::island_of(call, line),
283        "webhook" => {
284            let (path, handler) = arguments_of(call)?;
285            let mut route = new_route(Method::Post, path, handler, line);
286            route.webhook = Some(String::new());
287            return Ok(route);
288        }
289        "queue" => {
290            let (path, handler) = arguments_of(call)?;
291            let mut route = new_route(Method::Post, path, handler, line);
292            route.modifiers.push(Modifier::Queued);
293            return Ok(route);
294        }
295        _ => {}
296    }
297    let method = Method::from_routes_method(&call.method.to_string()).ok_or_else(|| {
298        format!(
299            "unknown route method `{}`; use get, post, put, patch, delete, screen, queue or webhook",
300            call.method
301        )
302    })?;
303    let (path, handler) = arguments_of(call)?;
304    Ok(new_route(method, path, handler, line))
305}
306
307pub(crate) fn new_route(method: Method, path: String, handler: Vec<String>, line: usize) -> Route {
308    Route {
309        method,
310        path,
311        handler,
312        screen: None,
313        island: None,
314        then: None,
315        modifiers: Vec::new(),
316        webhook: None,
317        line,
318    }
319}
320
321/// What each modifier is allowed on mirrors the builders `Routes` returns,
322/// so a chain the reader accepts is one the compiler accepts.
323fn apply_modifier(route: &mut Route, call: &ExprMethodCall, outermost: bool) -> Result<(), String> {
324    let method = call.method.to_string();
325    let webhook = route.webhook.is_some();
326    let action = route.method != Method::Get && !webhook;
327    let allowed = match method.as_str() {
328        "then_screen" | "then_redirect" | "then_follow" | "then_redirect_or_follow" => {
329            outermost && !webhook
330        }
331        "verify" => outermost && webhook,
332        "throttle" | "signed" | "feature" | "entitled" => !webhook,
333        "live" | "cached" => route.screen.is_some(),
334        "idempotent" | "file_limit" => action,
335        "document" => route.method == Method::Get && route.screen.is_none() && !webhook,
336        "body_limit" => action || webhook,
337        _ => false,
338    };
339    if !allowed {
340        return Err(misplaced(&method, route));
341    }
342    match method.as_str() {
343        "then_screen" | "then_redirect" | "then_follow" | "then_redirect_or_follow" => {
344            route.then = Some(then_of(call, route.method)?);
345        }
346        "document" => route
347            .modifiers
348            .push(Modifier::Document(one_argument(call)?)),
349        "file_limit" => route
350            .modifiers
351            .push(Modifier::FileLimit(one_argument(call)?)),
352        "verify" => route.webhook = Some(one_argument(call)?),
353        "throttle" => route
354            .modifiers
355            .push(Modifier::Throttle(one_argument(call)?)),
356        "body_limit" => route
357            .modifiers
358            .push(Modifier::BodyLimit(one_argument(call)?)),
359        "signed" => route.modifiers.push(no_argument(call, Modifier::Signed)?),
360        "feature" => route.modifiers.push(Modifier::Feature(one_argument(call)?)),
361        "entitled" => route
362            .modifiers
363            .push(Modifier::Entitled(one_argument(call)?)),
364        "live" => route.modifiers.push(Modifier::Live(one_argument(call)?)),
365        "cached" => route.modifiers.push(Modifier::Cached(one_argument(call)?)),
366        _ => route
367            .modifiers
368            .push(no_argument(call, Modifier::Idempotent)?),
369    }
370    Ok(())
371}
372
373fn misplaced(method: &str, route: &Route) -> String {
374    let kind = match (&route.webhook, route.method) {
375        (Some(_), _) => "a webhook, which takes `.body_limit(…)` and ends with `.verify(…)`",
376        (None, Method::Get) => {
377            "a page, which takes `.throttle(…)`, `.signed()`, `.feature(…)` and `.entitled(…)`, \
378             as a screen `.live(…)` and `.cached(…)`, and otherwise `.document(…)`"
379        }
380        (None, _) => {
381            "an action, which takes `.throttle(…)`, `.signed()`, `.feature(…)`, `.entitled(…)`, \
382             `.idempotent()`, `.body_limit(…)` and `.file_limit(…)`, then optionally \
383             `.then_screen(…)`, `.then_redirect(…)`, `.then_redirect_or_follow(…)` or \
384             `.then_follow()` last"
385        }
386    };
387    format!("`.{method}(…)` is not a route modifier `sofie gen` accepts here: this route is {kind}")
388}
389
390fn one_argument(call: &ExprMethodCall) -> Result<String, String> {
391    single_argument(call).ok_or_else(|| format!("`.{}(…)` takes exactly one argument", call.method))
392}
393
394fn no_argument<Written>(call: &ExprMethodCall, written: Written) -> Result<Written, String> {
395    if call.args.is_empty() {
396        return Ok(written);
397    }
398    Err(format!("`.{}()` takes no argument", call.method))
399}
400
401/// Only an action has a change to show, so only an action may refresh a
402/// screen or send a browser on.
403fn then_of(call: &ExprMethodCall, method: Method) -> Result<Then, String> {
404    if method == Method::Get {
405        return Err(format!(
406            "`.{}(…)` follows an action: `post`, `put`, `patch` or `delete`",
407            call.method
408        ));
409    }
410    let literals: Vec<String> = call.args.iter().filter_map(string_literal).collect();
411    if call.method == "then_redirect" {
412        return then_redirect_of(&literals, call.args.len());
413    }
414    if call.method == "then_redirect_or_follow" {
415        return match then_redirect_of(&literals, call.args.len())? {
416            Then::Redirect(path) => Ok(Then::RedirectOrFollow(path)),
417            other => Ok(other),
418        };
419    }
420    if call.method == "then_follow" {
421        return no_argument(call, Then::Follow);
422    }
423    match literals.as_slice() {
424        [screen, fragment]
425            if call.args.len() == 2 && is_plain_name(screen) && is_plain_name(fragment) =>
426        {
427            Ok(Then::Screen {
428                screen: screen.clone(),
429                fragment: fragment.clone(),
430            })
431        }
432        _ => Err(
433            "write `.then_screen(\"name\", \"fragment\")`: string literals naming a screen of \
434             this area and the id of an element in it (letters, digits, `_` and `-`)"
435                .to_owned(),
436        ),
437    }
438}
439
440/// A path of this app only, so the redirect cannot send anyone elsewhere; browsers drop tabs
441/// and line breaks from a URL, which would turn `/\t/host` into `//host`.
442fn then_redirect_of(literals: &[String], arguments: usize) -> Result<Then, String> {
443    match literals {
444        [path]
445            if arguments == 1
446                && path.starts_with('/')
447                && !path.starts_with("//")
448                && !path.contains(|character: char| {
449                    character == '\\' || character.is_whitespace() || character.is_control()
450                }) =>
451        {
452            Ok(Then::Redirect(path.clone()))
453        }
454        _ => Err(
455            "write `.then_redirect(\"/path\")`: one string literal, a path of this app \
456             starting with a single `/`, without whitespace or `\\`"
457                .to_owned(),
458        ),
459    }
460}
461
462fn is_receiver(expression: &Expr, receiver: &str) -> bool {
463    matches!(expression, Expr::Path(path) if path.qself.is_none() && path.path.is_ident(receiver))
464}
465
466fn arguments_of(call: &ExprMethodCall) -> Result<(String, Vec<String>), String> {
467    if call.turbofish.is_some() || call.args.len() != 2 {
468        return Err(format!(
469            "write the route as `.{}(\"/path\", handler)`: a literal path and a handler, nothing else",
470            call.method
471        ));
472    }
473    Ok((path_of(&call.args[0])?, handler_of(&call.args[1])?))
474}
475
476fn screen_of(call: &ExprMethodCall, line: usize) -> Result<Route, String> {
477    if call.turbofish.is_some() || call.args.len() != 3 {
478        return Err(
479            "write the screen as `.screen(\"/path\", \"name\", loader)`: a literal path, a \
480             literal template name and a loader, nothing else"
481                .to_owned(),
482        );
483    }
484    let screen = string_literal(&call.args[1])
485        .filter(|name| is_plain_name(name))
486        .ok_or_else(|| {
487            "the screen name must be a string literal of letters, digits, `_` and `-`, naming \
488             `screens/<name>.html` beside this file"
489                .to_owned()
490        })?;
491    let mut route = page_route(call, line)?;
492    route.screen = Some(screen);
493    Ok(route)
494}
495
496pub(crate) fn page_route(call: &ExprMethodCall, line: usize) -> Result<Route, String> {
497    Ok(new_route(
498        Method::Get,
499        path_of(&call.args[0])?,
500        handler_of(&call.args[2])?,
501        line,
502    ))
503}
504
505fn path_of(expression: &Expr) -> Result<String, String> {
506    let path = string_literal(expression).ok_or_else(|| {
507        "the route path must be a string literal, since `sofie gen` reads it without compiling; write it inline"
508            .to_owned()
509    })?;
510    if !path.starts_with('/') {
511        return Err(format!(
512            "the route path `{path}` must start with `/`, since it is joined to the area's prefix"
513        ));
514    }
515    Ok(path)
516}
517
518/// What the router would refuse only at boot, refused before the build.
519/// What the router serves and a browser can request; the CLI writes no route this refuses.
520pub fn check_route_path(path: &str) -> Result<(), String> {
521    if path.contains(char::is_whitespace) {
522        return Err(format!("the route path `{path}` holds whitespace"));
523    }
524    let segments: Vec<&str> = path.split('/').collect();
525    for (index, segment) in segments.iter().enumerate() {
526        // A browser resolves `.` and `..` before it sends the path, so no request reaches one.
527        if matches!(*segment, "." | "..") {
528            return Err(format!(
529                "the route path `{path}` has the segment `{segment}`, which a browser resolves \
530                 away, so no request reaches it"
531            ));
532        }
533        if !segment.contains(['{', '}']) {
534            continue;
535        }
536        let Some(parameter) = parameter(segment) else {
537            return Err(format!(
538                "the route path `{path}` has a malformed parameter in `{segment}`; write a \
539                 parameter as a whole segment, `{{name}}` or `{{*rest}}`"
540            ));
541        };
542        if parameter.starts_with('*') && index + 1 < segments.len() {
543            return Err(format!(
544                "the route path `{path}` has the catch-all `{segment}` before its end; a \
545                 catch-all takes the rest of the path, so it must be the last segment"
546            ));
547        }
548    }
549    Ok(())
550}
551
552fn parameter(segment: &str) -> Option<&str> {
553    let name = segment.strip_prefix('{')?.strip_suffix('}')?;
554    let plain = name.strip_prefix('*').unwrap_or(name);
555    let well_formed = !plain.is_empty()
556        && plain
557            .chars()
558            .all(|character| character.is_ascii_alphanumeric() || character == '_');
559    well_formed.then_some(name)
560}
561
562fn handler_of(expression: &Expr) -> Result<Vec<String>, String> {
563    handler_path(expression).ok_or_else(|| {
564        "the handler must be a plain path to a function, such as `actions::place_order`".to_owned()
565    })
566}
567
568fn handler_path(expression: &Expr) -> Option<Vec<String>> {
569    let Expr::Path(path) = expression else {
570        return None;
571    };
572    let plain = path.qself.is_none()
573        && path.path.leading_colon.is_none()
574        && path
575            .path
576            .segments
577            .iter()
578            .all(|segment| segment.arguments.is_none());
579    plain.then(|| segments(&path.path))
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    fn read(body: &str) -> Vec<Route> {
587        let source = format!("pub fn routes(routes: &mut Routes<Console>) {{\n{body}\n}}");
588        let syntax = syn::parse_file(&source).unwrap();
589        read_routes(
590            Path::new("routes.rs"),
591            &syntax,
592            Vec::new(),
593            &["order".to_owned()],
594        )
595        .unwrap()
596    }
597
598    fn shown(routes: &[Route]) -> Vec<String> {
599        routes
600            .iter()
601            .map(|route| {
602                format!(
603                    "{} {} {}",
604                    route.method.name(),
605                    route.path,
606                    route.handler.join("::")
607                )
608            })
609            .collect()
610    }
611
612    #[test]
613    fn a_resource_is_read_as_the_routes_it_adds() {
614        let routes = read(
615            "routes.group(\"/orders/{order}\", |lines| {\n\
616                 lines.resource::<OrderLine>().only([Resource::Index, Create]);\n\
617             });\n\
618             routes.resource::<Category>().only([Show]);\n\
619             routes.resource::<Tag>();\n\
620             routes.resource::<Order>();",
621        );
622
623        assert_eq!(
624            shown(&routes),
625            [
626                "GET /orders/{order}/order-lines sofie::resource::list_order_lines",
627                "GET /orders/{order}/order-lines/new sofie::resource::new_order_line",
628                "POST /orders/{order}/order-lines/new sofie::resource::create_order_line",
629                "GET /categories/{category} sofie::resource::show_category",
630                "GET /tags sofie::resource::list_tags",
631                "GET /tags/{tag} sofie::resource::show_tag",
632                "GET /tags/new sofie::resource::new_tag",
633                "POST /tags/new sofie::resource::create_tag",
634                "GET /tags/{tag}/edit sofie::resource::edit_tag",
635                "POST /tags/{tag}/edit sofie::resource::update_tag",
636                "GET /orders sofie::resource::list_orders",
637                "GET /orders/{order} sofie::resource::show_order",
638                "GET /orders/new sofie::resource::new_order",
639                "POST /orders/new sofie::resource::create_order",
640                "GET /orders/{order}/edit sofie::resource::edit_order",
641                "POST /orders/{order}/edit sofie::resource::update_order",
642                "POST /orders/{order}/delete sofie::resource::delete_order",
643            ]
644        );
645        assert_eq!(routes[0].screen.as_deref(), Some("order_line_index"));
646        assert_eq!(routes[2].screen, None);
647    }
648
649    #[test]
650    fn a_resource_keeps_only_screens_it_has() {
651        let source = "pub fn routes(routes: &mut Routes<Console>) { routes.resource::<Order>().only([Archive]); }";
652        let syntax = syn::parse_file(source).unwrap();
653        let error = read_routes(
654            Path::new("routes.rs"),
655            &syntax,
656            Vec::new(),
657            &["order".to_owned()],
658        )
659        .unwrap_err();
660
661        assert!(
662            error.message.contains("Index, Show, Create, Edit, Delete"),
663            "{error}"
664        );
665    }
666
667    #[test]
668    fn a_screen_is_live_and_cached_and_an_action_is_neither() {
669        let routes = read(
670            "routes.screen(\"/notes\", \"notes\", queries::notes)\n\
671                 .live(\"notes\")\n\
672                 .cached(Duration::from_secs(60));",
673        );
674        let source = "pub fn routes(routes: &mut Routes<Console>) { routes.post(\"/notes\", actions::write).cached(TIME); }";
675        let syntax = syn::parse_file(source).unwrap();
676        let error = read_routes(Path::new("routes.rs"), &syntax, Vec::new(), &[]).unwrap_err();
677
678        assert_eq!(
679            routes[0].modifiers,
680            [
681                Modifier::Live("\"notes\"".to_owned()),
682                Modifier::Cached("Duration::from_secs(60)".to_owned())
683            ]
684        );
685        assert!(
686            error
687                .message
688                .contains("`.cached(…)` is not a route modifier"),
689            "{error}"
690        );
691    }
692
693    #[test]
694    fn a_path_the_router_would_refuse_is_refused() {
695        let refused = |body: &str| {
696            let source = format!("pub fn routes(routes: &mut Routes<Console>) {{ {body} }}");
697            let syntax = syn::parse_file(&source).unwrap();
698            read_routes(Path::new("routes.rs"), &syntax, Vec::new(), &[])
699                .unwrap_err()
700                .message
701        };
702
703        let messages = [
704            refused("routes.get(\"orders\", queries::orders);"),
705            refused("routes.get(\"/orders/{id\", queries::order);"),
706            refused("routes.get(\"/orders/{*rest}/summary\", queries::summary);"),
707            refused(
708                "routes.group(\"/files/{*rest}\", |files| { files.get(\"/raw\", queries::raw); });",
709            ),
710            refused("routes.get(\"/orders/{}\", queries::order);"),
711            refused("routes.get(\"/my orders\", queries::orders);"),
712            refused("routes.get(\"/orders/../admin\", queries::orders);"),
713            refused("routes.get(\"/orders/./all\", queries::orders);"),
714        ];
715
716        let expected = [
717            "must start with `/`",
718            "malformed parameter",
719            "before its end",
720            "before its end",
721            "malformed parameter",
722            "holds whitespace",
723            "`..`",
724            "`.`",
725        ];
726        for (message, expected) in messages.iter().zip(expected) {
727            assert!(message.contains(expected), "{message}");
728        }
729    }
730
731    #[test]
732    fn a_redirect_that_a_browser_would_read_as_another_host_is_refused() {
733        for target in [
734            "//evil.example",
735            "/\\\\evil.example",
736            "/\\t/evil.example",
737            "/\\n/evil.example",
738            "/a b",
739        ] {
740            let source = format!(
741                "pub fn routes(routes: &mut Routes<Console>) {{ routes.post(\"/notes\", actions::write).then_redirect(\"{target}\"); }}"
742            );
743            let syntax = syn::parse_file(&source).unwrap();
744
745            let error = read_routes(Path::new("routes.rs"), &syntax, Vec::new(), &[]).unwrap_err();
746
747            assert!(error.message.contains("then_redirect"), "{target}: {error}");
748        }
749        let routes =
750            read("routes.post(\"/notes\", actions::write).then_redirect(\"/notes/done\");");
751        assert_eq!(
752            routes[0].then,
753            Some(Then::Redirect("/notes/done".to_owned()))
754        );
755    }
756
757    #[test]
758    fn a_queued_job_is_read_as_a_post_of_its_action() {
759        let routes = read("routes.queue(\"/exports\", actions::export).throttle(rate);");
760
761        assert_eq!(shown(&routes), ["POST /exports actions::export"]);
762        assert_eq!(
763            routes[0].modifiers,
764            [Modifier::Queued, Modifier::Throttle("rate".to_owned())]
765        );
766    }
767
768    #[test]
769    fn a_billed_action_reads_its_entitlement_and_following_the_provider() {
770        let routes = read(
771            "routes.get(\"/exports\", queries::exports).entitled(plans::EXPORT);\n\
772             routes.post(\"/checkout\", actions::checkout).entitled(plans::PRO).then_follow();",
773        );
774
775        assert_eq!(
776            routes[0].modifiers,
777            [Modifier::Entitled("plans::EXPORT".to_owned())]
778        );
779        assert_eq!(
780            routes[1].modifiers,
781            [Modifier::Entitled("plans::PRO".to_owned())]
782        );
783        assert_eq!(routes[1].then, Some(Then::Follow));
784        let source = "pub fn routes(routes: &mut Routes<Console>) { \
785                      routes.get(\"/plans\", queries::plans).then_follow(); }";
786        let error = read_routes(
787            Path::new("routes.rs"),
788            &syn::parse_file(source).unwrap(),
789            Vec::new(),
790            &[],
791        )
792        .unwrap_err();
793        assert!(error.message.contains("follows an action"), "{error}");
794    }
795
796    #[test]
797    fn documents_file_limits_and_a_redirect_that_may_follow_are_read() {
798        let routes = read(
799            "routes.get(\"/sitemap.xml\", queries::sitemap).document(Media::Xml);\n\
800             routes.post(\"/avatar\", actions::upload).file_limit(1_000_000);\n\
801             routes.post(\"/sign-in\", actions::sign_in).then_redirect_or_follow(\"/home\");",
802        );
803
804        assert_eq!(
805            routes[0].modifiers,
806            [Modifier::Document("Media::Xml".to_owned())]
807        );
808        assert_eq!(
809            routes[1].modifiers,
810            [Modifier::FileLimit("1_000_000".to_owned())]
811        );
812        assert_eq!(
813            routes[2].then,
814            Some(Then::RedirectOrFollow("/home".to_owned()))
815        );
816    }
817}