chemin/lib.rs
1//! Chemin is an enum-based router generator, supporting query strings and i18n. It can be used on front-end or back-end, with any
2//! framework or library. It can be used both ways: to parse a url into a route, and to generate a url from a route you constructed.
3//!
4//! It is not meant to be "blazingly fast": in this crate, code clarity is always privileged over optimization.
5//!
6//! ## Basic usage
7//!
8//! You just have to define your routes as different variant of an enum, derive the [Chemin] trait and that's it.
9//!
10//! ```
11//! use chemin::Chemin;
12//!
13//! // `PartialEq`, `Eq` and `Debug` are not necessary to derive `Chemin`, but here they are used to be able to use `assert_eq`.
14//! ##[derive(Chemin, PartialEq, Eq, Debug)]
15//! enum Route {
16//! ##[route("/")]
17//! Home,
18//!
19//! /// If there is a trailing slash at the end (example: #[route("/about/")]), it is considered
20//! /// a different route than without the trailing slash.
21//! ##[route("/about")]
22//! About,
23//!
24//! /// The character ":" is used for dynamic parameters.
25//! /// The type of the parameter (in this case `String`), must implement `FromStr` and `Display`.
26//! ##[route("/hello/:")]
27//! Hello(String),
28//!
29//! /// You can use named fields by giving a name to the parameters (after ":").
30//! ##[route("/hello/:name/:age")]
31//! HelloWithAge {
32//! name: String,
33//! age: u8
34//! }
35//! }
36//!
37//! // Url parsing:
38//! let decode_params = true; // Whether or not to percent-decode url parameters (see `Chemin::parse` for documentation).
39//! // `vec![]` is the list of the locales for this route. As we don't use i18n for this router yet, it is therefore empty.
40//! assert_eq!(Route::parse("/", decode_params), Some((Route::Home, vec![])));
41//! assert_eq!(Route::parse("/about", decode_params), Some((Route::About, vec![])));
42//! assert_eq!(Route::parse("/about/", decode_params), None); // Route not found because of the trailing slash
43//! assert_eq!(Route::parse("/hello/John", decode_params), Some((Route::Hello(String::from("John")), vec![])));
44//! assert_eq!(
45//! Route::parse("/hello/John%20Doe/30", decode_params),
46//! Some((
47//! Route::HelloWithAge {
48//! name: String::from("John Doe"),
49//! age: 30,
50//! },
51//! vec![],
52//! ))
53//! );
54//!
55//! // Url generation
56//! let encode_params = true; // Whether or not to percent-encode url parameters (see `Chemin::generate_url` for documentation).
57//! let locale = None; // The locale for which to generate the url. For now, we don't use i18n yet, so it is `None`.
58//! assert_eq!(Route::Home.generate_url(locale, encode_params), Some(String::from("/"))); // The result is guaranteed to be `Some` if we don't use i18n.
59//! assert_eq!(Route::About.generate_url(locale, encode_params), Some(String::from("/about")));
60//! assert_eq!(Route::Hello(String::from("John")).generate_url(locale, encode_params), Some(String::from("/hello/John")));
61//! assert_eq!(
62//! Route::HelloWithAge {
63//! name: String::from("John Doe"),
64//! age: 30,
65//! }.generate_url(locale, encode_params),
66//! Some(String::from("/hello/John%20Doe/30")),
67//! );
68//! ```
69//!
70//! ## Sub-routes
71//!
72//! But for more complex routers, you're not gonna put everything into a single enum. You can break it up with sub-routes:
73//!
74//! ```
75//! use chemin::Chemin;
76//!
77//! ##[derive(Chemin, PartialEq, Eq, Debug)]
78//! enum Route {
79//! /// You can use a sub-route by using ".." (only at the end of the path). The corresponding type must also implement `Chemin`.
80//! ///
81//! /// If you want a route to access "/sub-route" or "/sub-route/", it can't possibly be defined inside the sub-route, so it would
82//! /// have to be a different additional route here.
83//! ##[route("/sub-route/..")]
84//! WithSubRoute(SubRoute),
85//!
86//! /// You can also combine sub-route with url parameters, and use named sub-routes, by adding the name after "..".
87//! ##[route("/hello/:name/..sub_route")]
88//! HelloWithSubRoute {
89//! name: String,
90//! sub_route: SubRoute,
91//! },
92//! }
93//!
94//! ##[derive(Chemin, PartialEq, Eq, Debug)]
95//! enum SubRoute {
96//! ##[route("/a")]
97//! A,
98//!
99//! ##[route("/b")]
100//! B,
101//! }
102//!
103//! // Url parsing:
104//! assert_eq!(Route::parse("/sub-route/a", true), Some((Route::WithSubRoute(SubRoute::A), vec![])));
105//! assert_eq!(
106//! Route::parse("/hello/John/b", true),
107//! Some((
108//! Route::HelloWithSubRoute {
109//! name: String::from("John"),
110//! sub_route: SubRoute::B,
111//! },
112//! vec![],
113//! )),
114//! );
115//!
116//! // Url generation:
117//! assert_eq!(Route::WithSubRoute(SubRoute::A).generate_url(None, true), Some(String::from("/sub-route/a")));
118//! ```
119//!
120//! ## Query strings parameters
121//!
122//! Query strings are supported:
123//!
124//! ```
125//! use chemin::Chemin;
126//!
127//! ##[derive(Chemin, PartialEq, Eq, Debug)]
128//! enum Route {
129//! ##[route("/hello/:name")]
130//! Hello {
131//! name: String,
132//!
133//! /// This attribute can only be used on named fields
134//! ##[query_param]
135//! age: u8,
136//! }
137//! }
138//!
139//! // Url parsing:
140//! assert_eq!(Route::parse("/hello/John", true), None); // Route not found because the "age" query parameter wasn't provided
141//! assert_eq!(
142//! Route::parse("/hello/John?age=30", true),
143//! Some((
144//! Route::Hello {
145//! name: String::from("John"),
146//! age: 30,
147//! },
148//! vec![],
149//! ))
150//! );
151//!
152//! // Url generation:
153//! assert_eq!(
154//! Route::Hello {
155//! name: String::from("John"),
156//! age: 30,
157//! }.generate_url(None, true),
158//! Some(String::from("/hello/John?age=30")),
159//! );
160//! ```
161//!
162//! Query parameters can also be optional:
163//!
164//! ```
165//! use chemin::Chemin;
166//!
167//! ##[derive(Chemin, PartialEq, Eq, Debug)]
168//! enum Route {
169//! ##[route("/hello/:name")]
170//! Hello {
171//! name: String,
172//! ##[query_param(optional)]
173//! age: Option<u8>,
174//! }
175//! }
176//!
177//! // Url parsing:
178//! assert_eq!(
179//! Route::parse("/hello/John", true),
180//! Some((
181//! Route::Hello {
182//! name: String::from("John"),
183//! age: None,
184//! },
185//! vec![],
186//! )),
187//! );
188//! assert_eq!(
189//! Route::parse("/hello/John?age=30", true),
190//! Some((
191//! Route::Hello {
192//! name: String::from("John"),
193//! age: Some(30),
194//! },
195//! vec![],
196//! )),
197//! );
198//!
199//! // Url generation:
200//! assert_eq!(
201//! Route::Hello {
202//! name: String::from("John"),
203//! age: None,
204//! }.generate_url(None, true),
205//! Some(String::from("/hello/John")),
206//! );
207//! assert_eq!(
208//! Route::Hello {
209//! name: String::from("John"),
210//! age: Some(30),
211//! }.generate_url(None, true),
212//! Some(String::from("/hello/John?age=30")),
213//! );
214//! ```
215//!
216//! Query parameters can have a default value:
217//!
218//! ```
219//! use chemin::Chemin;
220//!
221//! ##[derive(Chemin, PartialEq, Eq, Debug)]
222//! enum Route {
223//! ##[route("/hello/:name")]
224//! Hello {
225//! name: String,
226//! ##[query_param(default = 20)]
227//! age: u8,
228//! }
229//! }
230//!
231//! // Url parsing:
232//! assert_eq!(
233//! Route::parse("/hello/John", true),
234//! Some((
235//! Route::Hello {
236//! name: String::from("John"),
237//! age: 20,
238//! },
239//! vec![],
240//! )),
241//! );
242//! assert_eq!(
243//! Route::parse("/hello/John?age=30", true),
244//! Some((
245//! Route::Hello {
246//! name: String::from("John"),
247//! age: 30,
248//! },
249//! vec![],
250//! )),
251//! );
252//!
253//! // Url generation:
254//! assert_eq!(
255//! Route::Hello {
256//! name: String::from("John"),
257//! age: 20,
258//! }.generate_url(None, true),
259//! Some(String::from("/hello/John")),
260//! );
261//! assert_eq!(
262//! Route::Hello {
263//! name: String::from("John"),
264//! age: 30,
265//! }.generate_url(None, true),
266//! Some(String::from("/hello/John?age=30")),
267//! );
268//! ```
269//!
270//! If you use sub-routes, you can have query parameters defined at any level of the "route tree", and they will all share the same
271//! query string.
272//!
273//! ## Internationalization (i18n)
274//!
275//! This crate allows you to have translations of your routes for different languages, by defining multiple paths on each enum variant
276//! and associating each with one or multiple locale codes
277//! (as used with <https://developer.mozilla.org/en-US/docs/Web/API/Navigator/language>):
278//!
279//! ```
280//! use chemin::Chemin;
281//!
282//! ##[derive(Chemin, PartialEq, Eq, Debug)]
283//! enum Route {
284//! ##[route("/")]
285//! Home,
286//!
287//! // Notice that the hyphens normally used in locale codes are here replaced by an underscore, to be valid rust identifiers
288//! ##[route(en, en_US, en_UK => "/about")]
289//! ##[route(fr, fr_FR => "/a-propos")]
290//! About,
291//!
292//! ##[route(en, en_US, en_UK => "/select/..")]
293//! ##[route(fr, fr_FR => "/selectionner/..")]
294//! Select(SelectRoute),
295//! }
296//!
297//! ##[derive(Chemin, PartialEq, Eq, Debug)]
298//! enum SelectRoute {
299//! ##[route(en, en_US => "/color/:/:/:")]
300//! ##[route(en_UK => "/colour/:/:/:")]
301//! ##[route(fr, fr_FR => "/couleur/:/:/:")]
302//! RgbColor(u8, u8, u8),
303//! }
304//!
305//! // Url parsing:
306//! assert_eq!(Route::parse("/", true), Some((Route::Home, vec![])));
307//!
308//! let about_english = Route::parse("/about", true).unwrap();
309//! assert_eq!(about_english.0, Route::About);
310//! // The `Vec<String>` of locales has to be asserted that way, because the order isn't guaranteed
311//! assert_eq!(about_english.1.len(), 3);
312//! assert!(about_english.1.contains(&"en"));
313//! assert!(about_english.1.contains(&"en-US")); // Notice that returned locale codes use hyphens and not underscores
314//! assert!(about_english.1.contains(&"en-UK"));
315//!
316//! let about_french = Route::parse("/a-propos", true).unwrap();
317//! assert_eq!(about_french.0, Route::About);
318//! assert_eq!(about_french.1.len(), 2);
319//! assert!(about_french.1.contains(&"fr"));
320//! assert!(about_french.1.contains(&"fr-FR"));
321//!
322//! let select_color_us_english = Route::parse("/select/color/0/255/0", true).unwrap();
323//! assert_eq!(select_color_us_english.0, Route::Select(SelectRoute::RgbColor(0, 255, 0)));
324//! assert_eq!(select_color_us_english.1.len(), 2); // The `Vec<String>` has to be asserted that way, because the order isn't guaranteed
325//! assert!(select_color_us_english.1.contains(&"en"));
326//! assert!(select_color_us_english.1.contains(&"en-US"));
327//!
328//! assert_eq!(
329//! Route::parse("/select/colour/0/255/0", true),
330//! Some((Route::Select(SelectRoute::RgbColor(0, 255, 0)), vec!["en-UK"])),
331//! );
332//!
333//! let select_color_french = Route::parse("/selectionner/couleur/0/255/0", true).unwrap();
334//! assert_eq!(select_color_french.0, Route::Select(SelectRoute::RgbColor(0, 255, 0)));
335//! assert_eq!(select_color_french.1.len(), 2); // The `Vec<String>` has to be asserted that way, because the order isn't guaranteed
336//! assert!(select_color_french.1.contains(&"fr"));
337//! assert!(select_color_french.1.contains(&"fr-FR"));
338//!
339//! assert_eq!(Route::parse("/select/couleur/0/255/0", true), None);
340//!
341//! // Url generation:
342//! assert_eq!(Route::Home.generate_url(Some("es"), true), Some(String::from("/")));
343//! assert_eq!(Route::Home.generate_url(None, true), Some(String::from("/")));
344//!
345//! // Notice that you have to use hyphens and not underscores in locale codes
346//! assert_eq!(Route::About.generate_url(Some("en"), true), Some(String::from("/about")));
347//! assert_eq!(Route::About.generate_url(Some("fr-FR"), true), Some(String::from("/a-propos")));
348//! assert_eq!(Route::About.generate_url(Some("es"), true), None);
349//! assert_eq!(Route::About.generate_url(None, true), None);
350//!
351//! assert_eq!(
352//! Route::Select(SelectRoute::RgbColor(0, 255, 0)).generate_url(Some("en-UK"), true),
353//! Some(String::from("/select/colour/0/255/0")),
354//! );
355//! assert_eq!(
356//! Route::Select(SelectRoute::RgbColor(0, 255, 0)).generate_url(Some("fr-FR"), true),
357//! Some(String::from("/selectionner/couleur/0/255/0")),
358//! );
359//! ```
360
361extern crate self as chemin;
362
363/// To derive the [Chemin] trait.
364///
365/// To learn how to use it, see [the root of the documentation](index.html).
366pub use chemin_macros::Chemin;
367
368use percent_encoding::AsciiSet;
369use qstring::QString;
370use smallvec::{SmallVec, ToSmallVec};
371use std::borrow::Cow;
372use std::fmt::Display;
373
374#[doc(hidden)]
375pub mod deps {
376 pub use once_cell;
377 pub use qstring;
378 pub use route_recognizer;
379}
380
381/// Trait to derive to build a enum-based router.
382///
383/// This trait is not meant to be implemented directly (although you can). To learn how to derive it, see
384/// [the root of the documentation](index.html).
385pub trait Chemin: Sized {
386 /// Parses an url to obtain a route.
387 ///
388 /// The `url` can contain a query string.
389 ///
390 /// If the `decode_params` argument is `true`, url parameters will be percent-decoded
391 /// (see <https://www.w3schools.com/tags/ref_urlencode.ASP>). However, the query string parameters will always be percent-decoded,
392 /// regardless of the `decode_params` argument. Additionally, the character "+" will be converted to a space (" ") for query string
393 /// parameters.
394 ///
395 /// If the provided url doesn't correspond to any route, or if some parameter or query string argument failed to parse, this
396 /// function returns [None]. If not, this function returns a tuple wrapped in [Some], whose first field is the obtained route, and
397 /// whose second field is a list of the locales corresponding to this route. Most of the time, it is only one locale, or zero if
398 /// no locale was defined for this route.
399 fn parse(url: &str, decode_params: bool) -> Option<(Self, Vec<Locale>)> {
400 let mut split = url.split('?').peekable();
401 let path = split.next()?;
402
403 let qstring = if split.peek().is_none() {
404 QString::default()
405 } else {
406 let qstring = split
407 .fold(String::new(), |mut qstring, fragment| {
408 qstring.push('?');
409 qstring.push_str(fragment);
410 qstring
411 })
412 .replace('+', "%20");
413 QString::from(&qstring[..])
414 };
415
416 Self::parse_with_accepted_locales(path, &AcceptedLocales::Any, decode_params, &qstring)
417 }
418
419 /// This function is not meant to be called directly. It is used internally by [Chemin::parse].
420 fn parse_with_accepted_locales(
421 path: &str,
422 accepted_locales: &AcceptedLocales,
423 decode_params: bool,
424 qstring: &QString,
425 ) -> Option<(Self, Vec<Locale>)>;
426
427 /// Generates a url from a route.
428 ///
429 /// The `locale` argument has to be [Some] when using i18n, because the locale for which the url is generated has to be known. If
430 /// this route is not specific to a locale, it can be [None]. It is a standard locale code, such as used with
431 /// <https://developer.mozilla.org/en-US/docs/Web/API/Navigator/language>.
432 ///
433 /// If the `encode_params` argument is `true`, url parameters will be percent-encoded
434 /// (see <https://www.w3schools.com/tags/ref_urlencode.ASP>). All non-alphanumeric characters except "-", "_", "." and "~" will be
435 /// encoded. However, the query string parameters will always be percent-encoded, regardless of the `encode_params` argument.
436 /// Additionally, the space character (" ") will be displayed as a "+" in query string parameters.
437 ///
438 /// If this route is not defined for the provided `locale`, then this method will return [None].
439 fn generate_url(&self, locale: Option<&str>, encode_params: bool) -> Option<String> {
440 let mut qstring = QString::default();
441
442 self.generate_url_and_build_qstring(locale, encode_params, &mut qstring)
443 .map(|mut value| {
444 if qstring.is_empty() {
445 value
446 } else {
447 value.push('?');
448 value.push_str(&qstring.to_string().replace('+', "%2B").replace("%20", "+"));
449 value
450 }
451 })
452 }
453
454 /// This method is not meant to be called directly. It is used internally by [Chemin::generate_url].
455 fn generate_url_and_build_qstring(
456 &self,
457 locale: Option<&str>,
458 encode_params: bool,
459 qstring: &mut QString,
460 ) -> Option<String>;
461}
462
463/// A standard locale code, such as used with <https://developer.mozilla.org/en-US/docs/Web/API/Navigator/language>.
464///
465/// Examples: `"en"`, `"en-US"`, `"fr"`, `"fr-FR"`, `"es-ES"`.
466pub type Locale = &'static str;
467
468#[doc(hidden)]
469#[cfg_attr(test, derive(PartialEq, Eq, Debug))]
470pub enum AcceptedLocales {
471 Any,
472 Some(SmallVec<[Locale; 1]>),
473}
474
475#[doc(hidden)]
476#[cfg_attr(test, derive(PartialEq, Eq, Debug))]
477pub enum RouteLocales {
478 Any,
479 Some(&'static [Locale]),
480}
481
482impl AcceptedLocales {
483 pub fn accept(&self, route_locales: &RouteLocales) -> bool {
484 match self {
485 AcceptedLocales::Any => true,
486
487 AcceptedLocales::Some(accepted_locales) => match route_locales {
488 RouteLocales::Any => true,
489
490 RouteLocales::Some(route_locales) => route_locales
491 .iter()
492 .any(|route_locale| accepted_locales.contains(route_locale)),
493 },
494 }
495 }
496
497 pub fn accepted_locales_for_sub_route(&self, route_locales: &RouteLocales) -> AcceptedLocales {
498 match self {
499 AcceptedLocales::Any => match route_locales {
500 RouteLocales::Any => AcceptedLocales::Any,
501 RouteLocales::Some(route_locales) => {
502 AcceptedLocales::Some(route_locales.to_smallvec())
503 }
504 },
505
506 AcceptedLocales::Some(accepted_locales) => match route_locales {
507 RouteLocales::Any => AcceptedLocales::Some(accepted_locales.clone()),
508
509 RouteLocales::Some(route_locales) => AcceptedLocales::Some(
510 intersect_locales(accepted_locales, route_locales).collect(),
511 ),
512 },
513 }
514 }
515
516 pub fn resulting_locales(&self, route_locales: &RouteLocales) -> Vec<Locale> {
517 match route_locales {
518 RouteLocales::Any => match self {
519 AcceptedLocales::Any => Vec::new(),
520 AcceptedLocales::Some(accepted_locales) => accepted_locales.to_vec(),
521 },
522
523 RouteLocales::Some(route_locales) => match self {
524 AcceptedLocales::Any => route_locales.to_vec(),
525 AcceptedLocales::Some(accepted_locales) => {
526 intersect_locales(accepted_locales, route_locales).collect()
527 }
528 },
529 }
530 }
531}
532
533fn intersect_locales<'a>(
534 accepted_locales: &'a SmallVec<[Locale; 1]>,
535 route_locales: &&'static [Locale],
536) -> impl Iterator<Item = Locale> + 'a {
537 route_locales
538 .iter()
539 .copied()
540 .filter(|route_locale| accepted_locales.contains(route_locale))
541}
542
543#[doc(hidden)]
544pub fn decode_param(param: &str) -> Option<Cow<str>> {
545 percent_encoding::percent_decode_str(param)
546 .decode_utf8()
547 .ok()
548}
549
550#[doc(hidden)]
551pub fn encode_param(param: impl Display) -> String {
552 static ASCII_SET: &AsciiSet = &percent_encoding::NON_ALPHANUMERIC
553 .remove(b'-')
554 .remove(b'_')
555 .remove(b'.')
556 .remove(b'~');
557 percent_encoding::utf8_percent_encode(¶m.to_string(), ASCII_SET).to_string()
558}
559
560#[cfg(test)]
561use smallvec::smallvec;
562
563#[test]
564fn test_accepted_locales_accept() {
565 assert!(AcceptedLocales::Any.accept(&RouteLocales::Any));
566 assert!(AcceptedLocales::Any.accept(&RouteLocales::Some(&["en", "fr"])));
567 assert!(AcceptedLocales::Some(smallvec!["en", "fr"]).accept(&RouteLocales::Any));
568 assert!(AcceptedLocales::Some(smallvec!["en", "fr"]).accept(&RouteLocales::Some(&["en", "fr"])));
569 assert!(AcceptedLocales::Some(smallvec!["en", "fr"]).accept(&RouteLocales::Some(&["en"])));
570 assert!(AcceptedLocales::Some(smallvec!["en", "fr"]).accept(&RouteLocales::Some(&["fr", "es"])));
571 assert!(!AcceptedLocales::Some(smallvec!["en", "fr"]).accept(&RouteLocales::Some(&["es"])));
572}
573
574#[test]
575fn test_accepted_locales_accepted_locales_for_sub_route() {
576 assert_eq!(
577 AcceptedLocales::Any.accepted_locales_for_sub_route(&RouteLocales::Any),
578 AcceptedLocales::Any,
579 );
580
581 assert_eq!(
582 AcceptedLocales::Any.accepted_locales_for_sub_route(&RouteLocales::Some(&["en", "fr"])),
583 AcceptedLocales::Some(smallvec!["en", "fr"]),
584 );
585
586 assert_eq!(
587 AcceptedLocales::Some(smallvec!["en", "fr"])
588 .accepted_locales_for_sub_route(&RouteLocales::Any),
589 AcceptedLocales::Some(smallvec!["en", "fr"]),
590 );
591
592 assert_eq!(
593 AcceptedLocales::Some(smallvec!["en", "fr"])
594 .accepted_locales_for_sub_route(&RouteLocales::Some(&["en", "fr"])),
595 AcceptedLocales::Some(smallvec!["en", "fr"]),
596 );
597
598 assert_eq!(
599 AcceptedLocales::Some(smallvec!["en", "fr"])
600 .accepted_locales_for_sub_route(&RouteLocales::Some(&["en", "es"])),
601 AcceptedLocales::Some(smallvec!["en"]),
602 );
603}
604
605#[test]
606fn test_accepted_locales_resulting_locales() {
607 assert_eq!(
608 AcceptedLocales::Any.resulting_locales(&RouteLocales::Any),
609 Vec::<Locale>::new(),
610 );
611
612 assert_eq!(
613 AcceptedLocales::Any.resulting_locales(&RouteLocales::Some(&["en", "fr"])),
614 vec!["en", "fr"],
615 );
616
617 assert_eq!(
618 AcceptedLocales::Some(smallvec!["en", "fr"]).resulting_locales(&RouteLocales::Any),
619 vec!["en", "fr"],
620 );
621
622 assert_eq!(
623 AcceptedLocales::Some(smallvec!["en", "fr"])
624 .resulting_locales(&RouteLocales::Some(&["en", "es"])),
625 vec!["en"],
626 );
627}
628
629#[test]
630fn test_derive() {
631 use maplit::hashset;
632 use std::collections::HashSet;
633
634 fn with_locales_vec_to_hashset(
635 value: Option<(Route, Vec<Locale>)>,
636 ) -> Option<(Route, HashSet<Locale>)> {
637 value.map(|(route, locales)| (route, HashSet::from_iter(locales)))
638 }
639
640 #[derive(Chemin, PartialEq, Eq, Debug)]
641 enum Route {
642 #[route("/")]
643 Home,
644
645 #[route("/hello")]
646 Hello,
647
648 #[route(en_US, en_UK => "/hello/:/")]
649 #[route(fr => "/bonjour/:/")]
650 HelloWithName(String),
651
652 #[route("/hello/:name/:age")]
653 HelloWithNameAndAge { name: String, age: u8 },
654
655 #[route(en, fr => "/with-sub-route/..")]
656 WithSubRoute(SubRoute),
657
658 #[route("/with-named-sub-route/..sub_route")]
659 WithNamedSubRoute {
660 sub_route: SubRoute,
661 #[query_param]
662 mandatory_param: String,
663 },
664 }
665
666 #[derive(Chemin, PartialEq, Eq, Debug)]
667 enum SubRoute {
668 #[route("/home")]
669 Home,
670
671 #[route(fr_FR, fr => "/bonjour")]
672 Hello,
673
674 #[route("/with-params")]
675 WithParams {
676 #[query_param(optional)]
677 optional_param: Option<String>,
678 #[query_param(default = String::from("default"))]
679 param_with_default_value: String,
680 },
681 }
682
683 // Test parsing
684 assert_eq!(Route::parse("", false), Some((Route::Home, vec![])));
685 assert_eq!(Route::parse("/", false), Some((Route::Home, vec![])));
686
687 assert_eq!(Route::parse("/hello", false), Some((Route::Hello, vec![])));
688 assert_eq!(Route::parse("/hello/", false), None);
689
690 assert_eq!(Route::parse("/hello/john", false), None);
691 assert_eq!(
692 with_locales_vec_to_hashset(Route::parse("/hello/john/", false)),
693 Some((
694 Route::HelloWithName(String::from("john")),
695 hashset!["en-US", "en-UK"],
696 ))
697 );
698 assert_eq!(Route::parse("/bonjour/john", false), None);
699 assert_eq!(
700 Route::parse("/bonjour/john%20doe/", false),
701 Some((Route::HelloWithName(String::from("john%20doe")), vec!["fr"])),
702 );
703 assert_eq!(
704 Route::parse("/bonjour/john%20doe/", true),
705 Some((Route::HelloWithName(String::from("john doe")), vec!["fr"]))
706 );
707
708 assert_eq!(Route::parse("/hello/john/invalid_age", false), None);
709 assert_eq!(
710 Route::parse("/hello/john/30", false),
711 Some((
712 Route::HelloWithNameAndAge {
713 name: String::from("john"),
714 age: 30,
715 },
716 vec![]
717 )),
718 );
719
720 assert_eq!(
721 with_locales_vec_to_hashset(Route::parse("/with-sub-route/home", false)),
722 Some((Route::WithSubRoute(SubRoute::Home), hashset!["en", "fr"])),
723 );
724 assert_eq!(Route::parse("/with-sub-route/bonjour/", false), None);
725 assert_eq!(
726 Route::parse("/with-sub-route/bonjour", false),
727 Some((Route::WithSubRoute(SubRoute::Hello), vec!["fr"])),
728 );
729
730 assert_eq!(
731 Route::parse("/with-named-sub-route/with-params", false),
732 None,
733 );
734 assert_eq!(
735 Route::parse(
736 "/with-named-sub-route/with-params?mandatory_param=value%20value+value",
737 false
738 ),
739 Some((
740 Route::WithNamedSubRoute {
741 sub_route: SubRoute::WithParams {
742 optional_param: None,
743 param_with_default_value: String::from("default"),
744 },
745 mandatory_param: String::from("value value value"),
746 },
747 vec![]
748 )),
749 );
750 assert_eq!(
751 Route::parse(
752 "/with-named-sub-route/with-params?optional_param=optional%2Bvalue&mandatory_param=value¶m_with_default_value=default+value",
753 false
754 ),
755 Some((
756 Route::WithNamedSubRoute {
757 sub_route: SubRoute::WithParams {
758 optional_param: Some(String::from("optional+value")),
759 param_with_default_value: String::from("default value"),
760 },
761 mandatory_param: String::from("value"),
762 },
763 vec![]
764 )),
765 );
766
767 // Test url generation
768 assert_eq!(
769 Route::Home.generate_url(None, false),
770 Some(String::from("/"))
771 );
772 assert_eq!(
773 Route::Home.generate_url(Some("es"), false),
774 Some(String::from("/")),
775 );
776
777 assert_eq!(
778 Route::Hello.generate_url(None, false),
779 Some(String::from("/hello")),
780 );
781
782 assert_eq!(
783 Route::HelloWithName(String::from("John")).generate_url(Some("en-US"), false),
784 Some(String::from("/hello/John/")),
785 );
786 assert_eq!(
787 Route::HelloWithName(String::from("John")).generate_url(Some("en-UK"), false),
788 Some(String::from("/hello/John/")),
789 );
790 assert_eq!(
791 Route::HelloWithName(String::from("John Doe")).generate_url(Some("fr"), false),
792 Some(String::from("/bonjour/John Doe/")),
793 );
794 assert_eq!(
795 Route::HelloWithName(String::from("John Doe.")).generate_url(Some("fr"), true),
796 Some(String::from("/bonjour/John%20Doe./"))
797 );
798 assert_eq!(
799 Route::HelloWithName(String::from("John")).generate_url(Some("en"), false),
800 None,
801 );
802 assert_eq!(
803 Route::HelloWithName(String::from("John")).generate_url(None, false),
804 None,
805 );
806
807 assert_eq!(
808 Route::HelloWithNameAndAge {
809 name: String::from("John"),
810 age: 30,
811 }
812 .generate_url(None, false),
813 Some(String::from("/hello/John/30")),
814 );
815
816 assert_eq!(
817 Route::WithSubRoute(SubRoute::Home).generate_url(None, false),
818 None
819 );
820 assert_eq!(
821 Route::WithSubRoute(SubRoute::Home).generate_url(Some("en"), false),
822 Some(String::from("/with-sub-route/home")),
823 );
824 assert_eq!(
825 Route::WithSubRoute(SubRoute::Hello).generate_url(Some("fr-FR"), false),
826 None,
827 );
828 assert_eq!(
829 Route::WithSubRoute(SubRoute::Hello).generate_url(Some("fr"), false),
830 Some(String::from("/with-sub-route/bonjour")),
831 );
832 assert_eq!(
833 Route::WithSubRoute(SubRoute::Hello).generate_url(Some("en"), false),
834 None,
835 );
836 assert_eq!(
837 Route::WithSubRoute(SubRoute::Hello).generate_url(None, false),
838 None,
839 );
840
841 assert_eq!(
842 Route::WithNamedSubRoute {
843 sub_route: SubRoute::WithParams {
844 optional_param: None,
845 param_with_default_value: String::from("default"),
846 },
847 mandatory_param: String::from("mandatory param"),
848 }
849 .generate_url(Some("en"), false),
850 Some(String::from(
851 "/with-named-sub-route/with-params?mandatory_param=mandatory+param"
852 ))
853 );
854 assert_eq!(
855 Route::WithNamedSubRoute {
856 sub_route: SubRoute::WithParams {
857 optional_param: Some(String::from("optional+param")),
858 param_with_default_value: String::from("default&value"),
859 },
860 mandatory_param: String::from("mandatory param"),
861 }
862 .generate_url(Some("en"), false),
863 Some(String::from(
864 "/with-named-sub-route/with-params?mandatory_param=mandatory+param&optional_param=optional%2Bparam¶m_with_default_value=default%26value"
865 ))
866 );
867}