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 [] => 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
262fn 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
321fn 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
401fn 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
440fn 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
518pub 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 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}