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(&param.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&param_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&param_with_default_value=default%26value"
865        ))
866    );
867}