sycamore_router/
lib.rs

1//! The Sycamore Router.
2
3#![warn(missing_docs)]
4#![deny(missing_debug_implementations)]
5
6// Alias self to sycamore_router for proc-macros.
7extern crate self as sycamore_router;
8
9mod router;
10
11use std::str::FromStr;
12
13pub use router::*;
14pub use sycamore_router_macro::Route;
15
16/// Trait that is implemented for `enum`s that can match routes.
17///
18/// This trait should not be implemented manually. Use the [`Route`](derive@Route) derive macro
19/// instead.
20pub trait Route: Sized + Default {
21    /// Matches a route with the given path segments. Note that in general, empty segments should be
22    /// filtered out before passed as an argument.
23    ///
24    /// It is likely that you are looking for the [`Route::match_path`] method instead.
25    fn match_route(&self, segments: &[&str]) -> Self;
26
27    /// Matches a route with the given path.
28    fn match_path(&self, path: &str) -> Self {
29        let segments = path
30            .split('/')
31            .filter(|s| !s.is_empty())
32            .collect::<Vec<_>>();
33        self.match_route(&segments)
34    }
35}
36
37/// Represents an URL segment or segments.
38#[derive(Clone, Debug)]
39pub enum Segment {
40    /// Match a specific segment.
41    Param(String),
42    /// Match an arbitrary segment that is captured.
43    DynParam,
44    /// Match an arbitrary amount of segments that are captured.
45    DynSegments,
46}
47
48/// Represents a capture of an URL segment or segments.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Capture<'a> {
51    /// A dynamic parameter in the URL (i.e. matches a single url segment).
52    DynParam(&'a str),
53    /// A dynamic segment in the URL (i.e. matches multiple url segments).
54    DynSegments(Vec<&'a str>),
55}
56
57impl<'a> Capture<'a> {
58    /// Attempts to cast the [`Capture`] to a [`Capture::DynParam`] with the matched url param.
59    pub fn as_dyn_param(&self) -> Option<&'a str> {
60        if let Self::DynParam(v) = self {
61            Some(*v)
62        } else {
63            None
64        }
65    }
66
67    /// Attempts to cast the [`Capture`] to a [`Capture::DynSegments`] with the matched url params.
68    pub fn as_dyn_segments(&self) -> Option<&[&'a str]> {
69        if let Self::DynSegments(v) = self {
70            Some(v)
71        } else {
72            None
73        }
74    }
75}
76
77/// A list of [`Segment`]s.
78#[derive(Clone, Debug)]
79pub struct RoutePath {
80    segments: Vec<Segment>,
81}
82
83impl RoutePath {
84    /// Create a new [`RoutePath`] from a list of [`Segment`]s.
85    pub fn new(segments: Vec<Segment>) -> Self {
86        Self { segments }
87    }
88
89    /// Attempt to match the path (url) with the current [`RoutePath`]. The path should already be
90    /// split around `/` characters.
91    pub fn match_path<'a>(&self, path: &[&'a str]) -> Option<Vec<Capture<'a>>> {
92        let mut paths = path.to_vec();
93        if let Some(last) = paths.last_mut() {
94            // Get rid of everything after '?' and '#' in the last segment.
95            *last = last.split('?').next().unwrap().split('#').next().unwrap();
96        }
97        let mut paths = paths.iter();
98        let mut segments = self.segments.iter();
99        let mut captures = Vec::new();
100
101        while let Some(segment) = segments.next() {
102            match segment {
103                Segment::Param(param) => {
104                    if paths.next() != Some(&param.as_str()) {
105                        return None;
106                    }
107                }
108                Segment::DynParam => {
109                    if let Some(p) = paths.next() {
110                        captures.push(Capture::DynParam(p));
111                    } else {
112                        return None;
113                    }
114                }
115                Segment::DynSegments => {
116                    if let Some(next_segment) = segments.next() {
117                        // Capture until match with Segment::Param.
118                        match next_segment {
119                            Segment::Param(next_param) => {
120                                let mut capture = Vec::new();
121                                for next_path in &mut paths {
122                                    if next_path == next_param {
123                                        captures.push(Capture::DynSegments(capture));
124                                        break;
125                                    } else {
126                                        capture.push(next_path);
127                                    }
128                                }
129                            }
130                            _ => unreachable!("segment following DynSegments cannot be dynamic"),
131                        }
132                    } else {
133                        // All remaining segments are captured.
134                        let mut capture = Vec::new();
135                        for next_path in &mut paths {
136                            capture.push(*next_path);
137                        }
138                        captures.push(Capture::DynSegments(capture));
139                    }
140                }
141            }
142        }
143
144        if paths.next().is_some() {
145            return None; // Leftover segments in paths.
146        }
147
148        Some(captures)
149    }
150}
151
152/// Fallible conversion between a param capture into a value.
153///
154/// Implemented for all types that implement [`FromStr`] by default.
155pub trait TryFromParam: Sized {
156    /// Creates a new value of this type from the given param. Returns `None` if the param cannot
157    /// be converted into a value of this type.
158    #[must_use]
159    fn try_from_param(param: &str) -> Option<Self>;
160}
161
162impl<T> TryFromParam for T
163where
164    T: FromStr,
165{
166    fn try_from_param(param: &str) -> Option<Self> {
167        param.parse().ok()
168    }
169}
170
171/// Fallible conversion between a list of param captures into a value.
172pub trait TryFromSegments: Sized {
173    /// Sets the value of the capture variable with the value of `segments`. Returns `false` if
174    /// unsuccessful (e.g. parsing error).
175    #[must_use]
176    fn try_from_segments(segments: &[&str]) -> Option<Self>;
177}
178
179impl<T> TryFromSegments for Vec<T>
180where
181    T: TryFromParam,
182{
183    fn try_from_segments(segments: &[&str]) -> Option<Self> {
184        let mut tmp = Vec::with_capacity(segments.len());
185        for segment in segments {
186            let value = T::try_from_param(segment)?;
187            tmp.push(value);
188        }
189        Some(tmp)
190    }
191}
192
193impl<T: Route> TryFromSegments for T {
194    fn try_from_segments(segments: &[&str]) -> Option<Self> {
195        // It's fine to use `default()` here for the Perseus use-case (TODO is there any situation
196        // where this wouldn't be fine?)
197        Some(Self::match_route(&Self::default(), segments))
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use Segment::*;
204
205    use super::*;
206
207    #[track_caller]
208    fn check(path: &str, route: RoutePath, expected: Option<Vec<Capture>>) {
209        let path = path
210            .split('/')
211            .filter(|s| !s.is_empty())
212            .collect::<Vec<_>>();
213        assert_eq!(route.match_path(&path), expected);
214    }
215
216    #[test]
217    fn index_path() {
218        check("/", RoutePath::new(Vec::new()), Some(Vec::new()));
219    }
220
221    #[test]
222    fn static_path_single_segment() {
223        check(
224            "/path",
225            RoutePath::new(vec![Param("path".to_string())]),
226            Some(Vec::new()),
227        );
228    }
229
230    #[test]
231    fn static_path_multiple_segments() {
232        check(
233            "/my/static/path",
234            RoutePath::new(vec![
235                Param("my".to_string()),
236                Param("static".to_string()),
237                Param("path".to_string()),
238            ]),
239            Some(Vec::new()),
240        );
241    }
242
243    #[test]
244    fn do_not_match_if_leftover_segments() {
245        check("/path", RoutePath::new(vec![]), None);
246        check(
247            "/my/static/path",
248            RoutePath::new(vec![Param("my".to_string()), Param("static".to_string())]),
249            None,
250        );
251    }
252
253    #[test]
254    fn dyn_param_single_segment() {
255        check(
256            "/abcdef",
257            RoutePath::new(vec![DynParam]),
258            Some(vec![Capture::DynParam("abcdef")]),
259        );
260    }
261
262    #[test]
263    fn dyn_param_with_leading_segment() {
264        check(
265            "/id/abcdef",
266            RoutePath::new(vec![Param("id".to_string()), DynParam]),
267            Some(vec![Capture::DynParam("abcdef")]),
268        );
269    }
270
271    #[test]
272    fn dyn_param_with_leading_and_trailing_segment() {
273        check(
274            "/id/abcdef/account",
275            RoutePath::new(vec![
276                Param("id".to_string()),
277                DynParam,
278                Param("account".to_string()),
279            ]),
280            Some(vec![Capture::DynParam("abcdef")]),
281        );
282    }
283
284    #[test]
285    fn dyn_param_final_missing_root() {
286        check("/", RoutePath::new(vec![DynParam]), None);
287    }
288
289    #[test]
290    fn dyn_param_final_missing() {
291        check(
292            "/id",
293            RoutePath::new(vec![Param("id".to_string()), DynParam]),
294            None,
295        );
296    }
297
298    #[test]
299    fn multiple_dyn_params() {
300        check(
301            "/a/b",
302            RoutePath::new(vec![DynParam, DynParam]),
303            Some(vec![Capture::DynParam("a"), Capture::DynParam("b")]),
304        );
305    }
306
307    #[test]
308    fn dyn_segments_at_root() {
309        check(
310            "/a/b/c",
311            RoutePath::new(vec![DynSegments]),
312            Some(vec![Capture::DynSegments(vec!["a", "b", "c"])]),
313        );
314    }
315
316    #[test]
317    fn dyn_segments_final() {
318        check(
319            "/id/a/b/c",
320            RoutePath::new(vec![Param("id".to_string()), DynSegments]),
321            Some(vec![Capture::DynSegments(vec!["a", "b", "c"])]),
322        );
323    }
324
325    #[test]
326    fn dyn_segments_capture_lazy() {
327        check(
328            "/id/a/b/c/end",
329            RoutePath::new(vec![
330                Param("id".to_string()),
331                DynSegments,
332                Param("end".to_string()),
333            ]),
334            Some(vec![Capture::DynSegments(vec!["a", "b", "c"])]),
335        );
336    }
337
338    #[test]
339    fn dyn_segments_can_capture_zero_segments() {
340        check(
341            "/",
342            RoutePath::new(vec![DynSegments]),
343            Some(vec![Capture::DynSegments(Vec::new())]),
344        );
345    }
346
347    #[test]
348    fn multiple_dyn_segments() {
349        check(
350            "/a/b/c/param/e/f/g",
351            RoutePath::new(vec![DynSegments, Param("param".to_string()), DynSegments]),
352            Some(vec![
353                Capture::DynSegments(vec!["a", "b", "c"]),
354                Capture::DynSegments(vec!["e", "f", "g"]),
355            ]),
356        );
357    }
358
359    #[test]
360    fn ignore_query_params_static() {
361        check(
362            "/a/b?foo=bar",
363            RoutePath::new(vec![Param("a".to_string()), Param("b".to_string())]),
364            Some(Vec::new()),
365        );
366    }
367
368    #[test]
369    fn ingnore_query_params_dyn() {
370        check(
371            "/a/b/c?foo=bar",
372            RoutePath::new(vec![DynSegments]),
373            Some(vec![Capture::DynSegments(vec!["a", "b", "c"])]),
374        );
375    }
376
377    #[test]
378    fn ignore_hash_static() {
379        check(
380            "/a/b#foo",
381            RoutePath::new(vec![Param("a".to_string()), Param("b".to_string())]),
382            Some(Vec::new()),
383        );
384    }
385
386    #[test]
387    fn ingnore_hash_dyn() {
388        check(
389            "/a/b/c#foo",
390            RoutePath::new(vec![DynSegments]),
391            Some(vec![Capture::DynSegments(vec!["a", "b", "c"])]),
392        );
393    }
394
395    mod integration {
396        use crate::*;
397
398        #[test]
399        fn simple_router() {
400            #[derive(Debug, PartialEq, Eq, Route)]
401            enum Routes {
402                #[to("/")]
403                Home,
404                #[to("/about")]
405                About,
406                #[not_found]
407                NotFound,
408            }
409
410            assert_eq!(Routes::match_route(&Routes::default(), &[]), Routes::Home);
411            assert_eq!(
412                Routes::match_route(&Routes::default(), &["about"]),
413                Routes::About
414            );
415            assert_eq!(
416                Routes::match_route(&Routes::default(), &["404"]),
417                Routes::NotFound
418            );
419            assert_eq!(
420                Routes::match_route(&Routes::default(), &["about", "something"]),
421                Routes::NotFound
422            );
423        }
424
425        #[test]
426        fn router_dyn_params() {
427            #[derive(Debug, PartialEq, Eq, Route)]
428            enum Routes {
429                #[to("/account/<id>")]
430                Account { id: u32 },
431                #[not_found]
432                NotFound,
433            }
434
435            assert_eq!(
436                Routes::match_route(&Routes::default(), &["account", "123"]),
437                Routes::Account { id: 123 }
438            );
439            assert_eq!(
440                Routes::match_route(&Routes::default(), &["account", "-1"]),
441                Routes::NotFound
442            );
443            assert_eq!(
444                Routes::match_route(&Routes::default(), &["account", "abc"]),
445                Routes::NotFound
446            );
447            assert_eq!(
448                Routes::match_route(&Routes::default(), &["account"]),
449                Routes::NotFound
450            );
451        }
452
453        #[test]
454        fn router_multiple_dyn_params() {
455            #[derive(Debug, PartialEq, Eq, Route)]
456            enum Routes {
457                #[to("/hello/<name>/<age>")]
458                Hello { name: String, age: u32 },
459                #[not_found]
460                NotFound,
461            }
462
463            assert_eq!(
464                Routes::match_route(&Routes::default(), &["hello", "Bob", "21"]),
465                Routes::Hello {
466                    name: "Bob".to_string(),
467                    age: 21
468                }
469            );
470            assert_eq!(
471                Routes::match_route(&Routes::default(), &["hello", "21", "Bob"]),
472                Routes::NotFound
473            );
474            assert_eq!(
475                Routes::match_route(&Routes::default(), &["hello"]),
476                Routes::NotFound
477            );
478        }
479
480        #[test]
481        fn router_multiple_dyn_segments() {
482            #[derive(Debug, PartialEq, Eq, Route)]
483            enum Routes {
484                #[to("/path/<path..>")]
485                Path { path: Vec<String> },
486                #[to("/numbers/<numbers..>")]
487                Numbers { numbers: Vec<u32> },
488                #[not_found]
489                NotFound,
490            }
491
492            assert_eq!(
493                Routes::match_route(&Routes::default(), &["path", "a", "b", "c"]),
494                Routes::Path {
495                    path: vec!["a".to_string(), "b".to_string(), "c".to_string()]
496                }
497            );
498            assert_eq!(
499                Routes::match_route(&Routes::default(), &["numbers", "1", "2", "3"]),
500                Routes::Numbers {
501                    numbers: vec![1, 2, 3]
502                }
503            );
504            assert_eq!(
505                Routes::match_route(&Routes::default(), &["path"]),
506                Routes::Path { path: Vec::new() }
507            );
508        }
509
510        #[test]
511        fn router_multiple_dyn_segments_match_lazy() {
512            #[derive(Debug, PartialEq, Eq, Route)]
513            enum Routes {
514                #[to("/path/<path..>/end")]
515                Path { path: Vec<u32> },
516                #[not_found]
517                NotFound,
518            }
519
520            assert_eq!(
521                Routes::match_route(&Routes::default(), &["path", "1", "2", "end"]),
522                Routes::Path { path: vec![1, 2] }
523            );
524            assert_eq!(
525                Routes::match_route(&Routes::default(), &["path", "end"]),
526                Routes::Path { path: Vec::new() }
527            );
528            assert_eq!(
529                Routes::match_route(&Routes::default(), &["path", "1", "end", "2"]),
530                Routes::NotFound
531            );
532        }
533
534        #[test]
535        fn router_dyn_param_before_dyn_segment() {
536            #[derive(Debug, PartialEq, Eq, Route)]
537            enum Routes {
538                #[to("/<param>/<segments..>")]
539                Path {
540                    param: String,
541                    segments: Vec<String>,
542                },
543                #[not_found]
544                NotFound,
545            }
546
547            assert_eq!(
548                Routes::match_route(&Routes::default(), &["path", "1", "2"]),
549                Routes::Path {
550                    param: "path".to_string(),
551                    segments: vec!["1".to_string(), "2".to_string()]
552                }
553            );
554        }
555
556        #[test]
557        fn nested_router() {
558            #[derive(Debug, PartialEq, Eq, Route)]
559            enum Nested {
560                #[to("/nested")]
561                Nested,
562                #[not_found]
563                NotFound,
564            }
565
566            #[derive(Debug, PartialEq, Eq, Route)]
567            enum Routes {
568                #[to("/")]
569                Home,
570                #[to("/route/<_..>")]
571                Route(Nested),
572                #[not_found]
573                NotFound,
574            }
575
576            assert_eq!(Routes::match_route(&Routes::default(), &[]), Routes::Home);
577            assert_eq!(
578                Routes::match_route(&Routes::default(), &["route", "nested"]),
579                Routes::Route(Nested::Nested)
580            );
581            assert_eq!(
582                Routes::match_route(&Routes::default(), &["route", "404"]),
583                Routes::Route(Nested::NotFound)
584            );
585            assert_eq!(
586                Routes::match_route(&Routes::default(), &["404"]),
587                Routes::NotFound
588            );
589        }
590    }
591}